Formidable Forms <= 6.28 - Missing Authorization to Unauthenticated Payment Integrity Bypass via PaymentIntent Reuse
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:NTechnical Details
What Changed in the Fix
Changes introduced in v6.29
Source Code
WordPress.org SVN# 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
inithook or a specific listener that looks for thefrm_stripe_returnorfrm_stripe_link_returnparameter in a GET request. - Vulnerable Functions:
handle_one_time_stripe_link_return_urlandverify_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_idorentry_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)
- Entry Point: A user submits a Formidable Form configured for payment. An entry is created with a
pendingstatus. - Payment Redirect: The plugin redirects the user to Stripe or provides a Stripe Link.
- 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 - Validation Logic:
handle_one_time_stripe_link_return_url()is triggered.- It calls
verify_intent($intent_id, $client_secret). verify_intentchecks if theclient_secretmatches theintent_id(by querying Stripe API). It returnstruebecause 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 asPaid/Completewithout checking if$intent->amountequals the price of the item in entry456.
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):
- Shortcode: The form is rendered via the
[formidable id=X]shortcode. - Page Navigation: Navigate to the page containing the payment form using
browser_navigate. - Extraction: Use
browser_evalto extract nonces.- Variable:
frmStripeVars(standard for this plugin's Stripe integration). - Command:
browser_eval("window.frmStripeVars?.nonce")orbrowser_eval("jQuery('input[name=\"_wpnonce\"]').val()").
- Variable:
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)
- Submit a form configured for a $1.00 payment (Form A).
- Complete the payment flow legitimately.
- Capture the
payment_intent(e.g.,pi_LOWVALUE) andpayment_intent_client_secret(e.g.,pi_LOWVALUE_secret_XYZ) from the redirect URL.
Step 2: Create a High-Value Submission
- Submit a form configured for a $1000.00 payment (Form B).
- Capture the
entry_id(e.g.,789) created by this submission. - 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 triggershandle_one_time_stripe_link_return_url)payment_intent:pi_LOWVALUEpayment_intent_client_secret:pi_LOWVALUE_secret_XYZentry:789(The high-value entry ID)
6. Test Data Setup
- Form A (Cheap): Create a form (ID: 1) with a single Payment field set to a fixed amount of
$1.00. Enable Stripe Lite. - Form B (Expensive): Create a form (ID: 2) with a Payment field set to
$1000.00. - 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 ofcompleteorPaidin the database, despite no payment being made for that specific amount.
8. Verification Steps
- WP-CLI Entry Check:
wp formidable entry get 789 --field=is_draft
(Confirm it is no longer a draft/pending). - 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'). - Amount Comparison:
Check if thewp_frm_paymentsrecord shows the amount from thePaymentIntent($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
amountfield of the Intent against the price defined in the Form. - Action Verification: If the
inithook fails, try targeting the AJAX handler for Stripe returns:action=frm_stripe_link_return&payment_intent=...&...viaadmin-ajax.php.
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
@@ -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.