CVE-2026-2890

Formidable Forms <= 6.28 - Missing Authorization to Unauthenticated Payment Integrity Bypass via PaymentIntent Reuse

highMissing Authorization
7.5
CVSS Score
7.5
CVSS Score
high
Severity
6.29
Patched in
1d
Time to patch

Description

The Formidable Forms plugin for WordPress is vulnerable to a payment integrity bypass in all versions up to, and including, 6.28. This is due to the Stripe Link return handler (`handle_one_time_stripe_link_return_url`) marking payment records as complete based solely on the Stripe PaymentIntent status without comparing the intent's charged amount against the expected payment amount, and the `verify_intent()` function validating only client secret ownership without binding intents to specific forms or actions. This makes it possible for unauthenticated attackers to reuse a PaymentIntent from a completed low-value payment to mark a high-value payment as complete, effectively bypassing payment for goods or services.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
None
Confidentiality
High
Integrity
None
Availability

Technical Details

Affected versions<=6.28
PublishedMarch 12, 2026
Last updatedMarch 13, 2026
Affected pluginformidable

What Changed in the Fix

Changes introduced in v6.29

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Vulnerability Research Plan: CVE-2026-2890 Payment Integrity Bypass ## 1. Vulnerability Summary The **Formidable Forms** plugin (up to version 6.28) contains a flaw in its Stripe Lite payment integration. The function `handle_one_time_stripe_link_return_url` processes successful payment returns b…

Show full research plan

Vulnerability Research Plan: CVE-2026-2890 Payment Integrity Bypass

1. Vulnerability Summary

The Formidable Forms plugin (up to version 6.28) contains a flaw in its Stripe Lite payment integration. The function handle_one_time_stripe_link_return_url processes successful payment returns but fails to verify that the amount associated with the Stripe PaymentIntent matches the expected amount for the specific form entry being processed. Furthermore, the verify_intent() function only checks if the user possesses the client_secret for a given PaymentIntent, without ensuring that the intent is bound to the current transaction, form, or entry.

This allow an unauthenticated attacker to complete a low-value payment (e.g., $1.00), obtain the resulting PaymentIntent ID and client_secret, and then reuse those credentials to "complete" a high-value payment (e.g., $1,000.00) for a different form submission.

2. Attack Vector Analysis

  • Endpoint: The return URL handler for Stripe Link payments. This is typically triggered via an init hook or a specific listener that looks for the frm_stripe_return or frm_stripe_link_return parameter in a GET request.
  • Vulnerable Functions: handle_one_time_stripe_link_return_url and verify_intent().
  • Payload Parameters:
    • payment_intent: The ID of a previously succeeded low-value payment.
    • payment_intent_client_secret: The secret associated with the low-value payment.
    • form_id or entry_id: The ID of the high-value submission to be "paid."
  • Authentication: Unauthenticated.
  • Preconditions:
    • Stripe Lite must be enabled in Formidable Settings.
    • A form must exist that accepts payments.

3. Code Flow (Inferred from Patch Description)

  1. Entry Point: A user submits a Formidable Form configured for payment. An entry is created with a pending status.
  2. Payment Redirect: The plugin redirects the user to Stripe or provides a Stripe Link.
  3. Completion Handler: After "payment," the user is redirected back to a URL like:
    https://victim.com/?frm_stripe_return=1&payment_intent=pi_123&payment_intent_client_secret=pi_123_secret_abc&entry=456
  4. Validation Logic:
    • handle_one_time_stripe_link_return_url() is triggered.
    • It calls verify_intent($intent_id, $client_secret).
    • verify_intent checks if the client_secret matches the intent_id (by querying Stripe API). It returns true because the attacker is using a valid secret for a valid intent.
    • The handler checks if $intent->status === 'succeeded'.
    • Vulnerable Step: It finds the entry (e.g., ID 456) and marks it as Paid/Complete without checking if $intent->amount equals the price of the item in entry 456.

4. Nonce Acquisition Strategy

Based on the provided source code, payment nonces are likely handled within the Stripe-specific controllers (e.g., FrmTransLiteController or FrmStripeLiteController), which are not in the provided snippet. However, Stripe return handlers often bypass traditional WordPress nonces because they are intended for callbacks from external services.

If a nonce is required for the initial form submission (frm_entries_create):

  1. Shortcode: The form is rendered via the [formidable id=X] shortcode.
  2. Page Navigation: Navigate to the page containing the payment form using browser_navigate.
  3. Extraction: Use browser_eval to extract nonces.
    • Variable: frmStripeVars (standard for this plugin's Stripe integration).
    • Command: browser_eval("window.frmStripeVars?.nonce") or browser_eval("jQuery('input[name=\"_wpnonce\"]').val()").

5. Exploitation Strategy

The goal is to use a completed $1.00 transaction ID to satisfy a $100.00 requirement.

Step 1: Obtain a Successful PaymentIntent (Low Value)

  1. Submit a form configured for a $1.00 payment (Form A).
  2. Complete the payment flow legitimately.
  3. Capture the payment_intent (e.g., pi_LOWVALUE) and payment_intent_client_secret (e.g., pi_LOWVALUE_secret_XYZ) from the redirect URL.

Step 2: Create a High-Value Submission

  1. Submit a form configured for a $1000.00 payment (Form B).
  2. Capture the entry_id (e.g., 789) created by this submission.
  3. When the plugin expects you to pay for Form B, stop and do not pay.

Step 3: Trigger the Bypass

Send a GET request to the return handler using the credentials from Step 1 against the ID from Step 2.

Request Configuration:

  • URL: http://localhost:8080/
  • Method: GET
  • Parameters:
    • frm_stripe_return: 1 (or the parameter that triggers handle_one_time_stripe_link_return_url)
    • payment_intent: pi_LOWVALUE
    • payment_intent_client_secret: pi_LOWVALUE_secret_XYZ
    • entry: 789 (The high-value entry ID)

6. Test Data Setup

  1. Form A (Cheap): Create a form (ID: 1) with a single Payment field set to a fixed amount of $1.00. Enable Stripe Lite.
  2. Form B (Expensive): Create a form (ID: 2) with a Payment field set to $1000.00.
  3. Stripe Mock: In a test environment, ensure Stripe is in "Test Mode" so pi_ IDs can be generated without real money.

7. Expected Results

  • The HTTP response from the bypass request should indicate a successful redirect (302) to the form's "Success" page.
  • The entry with ID 789 (High Value) should now have a transaction status of complete or Paid in the database, despite no payment being made for that specific amount.

8. Verification Steps

  1. WP-CLI Entry Check:
    wp formidable entry get 789 --field=is_draft
    (Confirm it is no longer a draft/pending).
  2. Database Transaction Check:
    wp db query "SELECT * FROM wp_frm_payments WHERE item_id=789;"
    (Verify a payment record exists and is marked as 'complete').
  3. Amount Comparison:
    Check if the wp_frm_payments record shows the amount from the PaymentIntent ($1.00) while the form entry is for $1000.00.

9. Alternative Approaches

If the entry ID is not passed via GET, it may be stored in the PHP Session or the Stripe PaymentIntent metadata.

  • Metadata Check: If the plugin relies on metadata saved on the Intent at creation, this bypass is only possible if the plugin fails to verify the amount field of the Intent against the price defined in the Form.
  • Action Verification: If the init hook fails, try targeting the AJAX handler for Stripe returns:
    action=frm_stripe_link_return&payment_intent=...&... via admin-ajax.php.
Research Findings
Static analysis — not yet PoC-verified

Summary

Formidable Forms (<= 6.28) is vulnerable to a payment integrity bypass where unauthenticated attackers can reuse a successful Stripe PaymentIntent from a low-value transaction to mark a high-value transaction as complete. This occurs because the return handler validates the client secret but fails to verify that the PaymentIntent's amount matches the expected amount for the specific form entry.

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/formidable/6.28/classes/controllers/FrmAddonsController.php /home/deploy/wp-safety.org/data/plugin-versions/formidable/6.29/classes/controllers/FrmAddonsController.php
--- /home/deploy/wp-safety.org/data/plugin-versions/formidable/6.28/classes/controllers/FrmAddonsController.php	2026-02-12 17:50:48.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/formidable/6.29/classes/controllers/FrmAddonsController.php	2026-03-11 18:49:02.000000000 +0000
@@ -194,10 +194,12 @@
 
 		// Extract the elements to move
 		foreach ( $plans as $plan ) {
-			if ( isset( self::$categories[ $plan ] ) ) {
-				$bottom_categories[ $plan ] = self::$categories[ $plan ];
-				unset( self::$categories[ $plan ] );
+			if ( ! isset( self::$categories[ $plan ] ) ) {
+				continue;
 			}
+
+			$bottom_categories[ $plan ] = self::$categories[ $plan ];
+			unset( self::$categories[ $plan ] );
 		}
 
 		$special_categories = array();
@@ -287,12 +289,12 @@
 		$addons = $api->get_api_info();
 
 		if ( ! $addons ) {
-			$addons = self::fallback_plugin_list();
-		} else {
-			foreach ( $addons as $k => $addon ) {
-				if ( empty( $addon['excerpt'] ) && $k !== 'error' ) {
-					unset( $addons[ $k ] );
-				}
+			return self::fallback_plugin_list();
+		}
+
+		foreach ( $addons as $k => $addon ) {
+			if ( empty( $addon['excerpt'] ) && $k !== 'error' ) {
+				unset( $addons[ $k ] );
 			}
 		}
 
@@ -726,6 +728,45 @@
 	}
 
 	/**
+	 * Get the JSON-encoded install data for a plugin update.
+	 *
+	 * @since 6.29
+	 *
+	 * @param string $addon_slug The addon slug (e.g. 'pro', 'dates').
+	 *
+	 * @return string JSON-encoded install data, or empty string if no URL is available.
+	 */
+	public static function get_update_install_data( $addon_slug ) {
+		$upgrading = self::install_link( $addon_slug );
+
+		if ( isset( $upgrading['class'] ) && 'frm-install-addon' === $upgrading['class'] ) {
+			return (string) json_encode( $upgrading );
+		}
+
+		if ( 'pro' === $addon_slug ) {
+			$download_url = self::get_pro_download_url();
+			$plugin_file  = 'formidable-pro/formidable-pro.php';
+		} else {
+			$addon_data   = self::get_addon( $addon_slug );
+			$download_url = $addon_data && ! empty( $addon_data['url'] ) ? $addon_data['url'] : '';
+			$plugin_file  = $addon_data && ! empty( $addon_data['plugin'] ) ? $addon_data['plugin'] : 'formidable-' . $addon_slug . '/formidable-' . $addon_slug . '.php';
+		}
+
+		if ( ! $download_url ) {
+			$update_plugins = get_site_transient( 'update_plugins' );
+			$plugin_update  = $update_plugins->response[ $plugin_file ] ?? null;
+			$download_url   = $plugin_update && ! empty( $plugin_update->package ) ? $plugin_update->package : '';
+		}
+
+		return $download_url ? (string) json_encode(
+			array(
+				'url'   => $download_url,
+				'class' => 'frm-install-addon',
+			)
+		) : '';
+	}
+
+	/**
 	 * @since 4.09
 	 *
 	 * @param string $plugin The plugin slug.
@@ -821,6 +862,11 @@
 
 			$addon['installed'] = self::is_installed( $file_name );
 
+			if ( 'highrise' === $slug && ! $addon['installed'] ) {
+				unset( $addons[ $id ] );
+				continue;
+			}
+
 			if ( $addon['installed'] && 'formidable-views/formidable-views.php' === $file_name ) {
 				$active_views_version = self::get_active_views_version();
 
@@ -998,7 +1044,7 @@
 	 * @return string
 	 */
 	protected static function get_current_plugin() {
-		if ( empty( self::$plugin ) ) {
+		if ( ! self::$plugin ) {
 			self::$plugin = FrmAppHelper::get_param( 'plugin', '', 'post', 'esc_url_raw' );
 		}
 		return self::$plugin;
@@ -1100,7 +1146,7 @@
 
 		// Create the plugin upgrader with our custom skin.
 		$installer = new Plugin_Upgrader( new FrmInstallerSkin() );
-		$installer->install( $download_url );
+		$installer->install( $download_url, array( 'overwrite_package' => true ) );
 
 		// Flush the cache and return the newly installed plugin basename.
 		wp_cache_flush();

Exploit Outline

1. Legitimately complete a low-value transaction (e.g., $1.00) using a Formidable form configured with Stripe Lite. 2. Capture the 'payment_intent' ID and 'payment_intent_client_secret' from the successful redirect URL. 3. Submit a separate high-value form entry (e.g., $1000.00) and obtain its 'entry_id', but do not proceed with the actual payment. 4. Craft a GET request to the site's return handler (triggering `handle_one_time_stripe_link_return_url`) using the high-value 'entry_id' as the target, but providing the 'payment_intent' and 'payment_intent_client_secret' from the successful $1.00 transaction. 5. The plugin validates that the low-value secret matches the intent and that the intent is 'succeeded', then incorrectly marks the high-value entry as fully paid in the database because it fails to verify that the intent amount matches the entry price.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.