Business Logic & Race Condition Flaws
On this page
Bugs No Scanner Will Find#
Technical vulnerabilities like injection, XSS and broken crypto are violations of a rule a tool can encode, so scanners can find many of them. Business logic flaws are different: the code does exactly what it was written to do, and that intended behaviour is the vulnerability. There is no malformed input and no signature to match, because the flaw is in the assumptions, not the syntax. That is why these bugs survive automated testing and often carry the highest impact, since they convert directly into money, data or privilege.
Race Conditions and TOCTOU#
A time-of-check to time-of-use (TOCTOU) flaw exists whenever an application checks a condition and then acts on it, with a gap in between that a second request can slip through. Classic cases:
- Coupon or gift-card reuse: two requests redeem the same single-use code at the same moment; both read "unused" before either marks it used.
- Overdraft: two withdrawals each read a balance of 100 and each is approved, producing 200 in withdrawals from a 100 balance.
- One-per-account limits: several concurrent requests each see "not yet claimed" and all succeed.
The window is small but attackers widen their odds by firing many requests in parallel. With HTTP/2 you can send them almost simultaneously (the "single-packet attack"), and even a plain thread pool is enough to demonstrate the flaw:
import concurrent.futures as cf
import requests
URL = "https://shop.example/api/redeem"
COOKIES = {"session": "..."} # an authenticated session you own, on a test system
def redeem():
r = requests.post(URL, json={"code": "SAVE50"}, cookies=COOKIES, timeout=10)
return r.status_code, r.json().get("balance")
# Fire 30 identical requests as close together as possible
with cf.ThreadPoolExecutor(max_workers=30) as pool:
results = [f.result() for f in [pool.submit(redeem) for _ in range(30)]]
successes = sum(1 for status, _ in results if status == 200)
print(f"{successes} of {len(results)} redemptions succeeded")
On a safe implementation exactly one request returns 200 and the rest are rejected. On a vulnerable one, several succeed, and the printed count is your proof. Only run this against systems you own or are authorised to test.
The fix is atomicity: make the check and the update a single indivisible operation so there is no gap.
-- Vulnerable: check then update, with a race in between
SELECT used FROM coupons WHERE code = 'SAVE50'; -- app sees used = false
UPDATE coupons SET used = true WHERE code = 'SAVE50';
-- Safe: one atomic conditional update; the database serialises concurrent callers
UPDATE coupons SET used = true, used_by = :user
WHERE code = 'SAVE50' AND used = false;
-- then, in the app, act only if rows-affected == 1
The safe version relies on the database to serialise writes to the same row. Exactly one caller gets rows-affected = 1; every other caller gets 0 and must be rejected. Other tools for the same job: a unique constraint (a UNIQUE(coupon_code, user_id) on a redemptions table turns a double redemption into a duplicate-key error), SELECT ... FOR UPDATE row locks inside a transaction, and idempotency keys so a retried request cannot double-apply. Checking the affected-row count is the step people forget; without it the atomic query is wasted.
Workflow Bypass#
Multi-step flows assume the steps happen in order, but the client controls the order of requests. Attackers jump straight to /checkout/confirm without paying, submit the final step of a wizard without the earlier ones, or reach a "post-verification" page before verifying. Defences:
- Keep the flow state on the server, tied to the session, and refuse a step whose prerequisites are not recorded as complete.
- Never trust a client-supplied
step,statusorstagevalue. - Re-validate every prerequisite at the final, consequential action, not just at each intermediate screen.
Abusing Legitimate Features#
Some flaws weaponise features exactly as built, by pushing values past the range the designer imagined:
- Negative or huge quantities that make a total go negative or overflow.
- Currency or unit confusion, paying in a weaker currency than the price assumes.
- Refund abuse: refunding the same order twice, or to a different account than paid.
- Discount stacking of promotions meant to be mutually exclusive.
- Referral loops: self-referral or circular referrals farming credit.
A single guard covers a surprising number of these, validating that a value is within its sensible domain before it is used:
def add_to_cart(item_id: str, quantity, unit_price_cents: int):
if not isinstance(quantity, int) or not (1 <= quantity <= 100):
raise ValueError("quantity must be an integer between 1 and 100")
# NEVER trust a client-sent price; look it up from authoritative data
real_price = PRICE_BOOK[item_id]
return {"item": item_id, "quantity": quantity, "line_total": quantity * real_price}
The two ideas in four lines: reject out-of-range quantities (killing the negative-total trick), and recompute the price server-side instead of accepting whatever the client posted.
Parameter Tampering and Where Trust Belongs#
Applications routinely trust values they should recompute: prices, totals, user IDs, roles, discount percentages, account numbers. The rule is simple to state and easy to violate under deadline pressure: every security- or money-relevant value is derived on the server from authoritative data, and any client-supplied copy is a hint to validate, never a source of truth. A checkout that reads total from the request instead of summing the cart server-side is one hidden-field edit away from a free order. Changing an account_id in a request to someone else's is IDOR; the same "trust the client" mistake underlies both.
Finding Logic Flaws#
Because tools miss them, discovery is manual and adversarial. Walk each user journey and, at every step, ask:
- What does the server assume has already happened before this request?
- What if I do this twice, in parallel, or out of order?
- What if I send a value the UI would never let me send: negative, zero, enormous, a different user's ID, a currency the price is not in?
- What is the invariant the business depends on ("a coupon is used at most once", "you cannot spend more than your balance", "only the owner can cancel an order"), and how could I break it while every individual check still passes?
Threat modeling is the structured way to generate these questions before code exists; probing them afterwards is a core part of a penetration test. Write down each invariant explicitly, because a flaw is usually an invariant nobody stated and therefore nobody enforced.
Why They Are So Durable#
A code-level bug is fixed by patching a line. A logic flaw is a wrong assumption about how the system will be used, so patching one symptom (blocking negative quantities) leaves the underlying gap (trusting client-supplied numbers) open elsewhere. The lasting fix is to make the invariant explicit and enforce it in one authoritative place, so every path is covered rather than each abuse being whacked as it appears.
Practice#
Take a flow you know, such as applying a promo code or transferring balance. Write down its invariants in one sentence each. Then, on a test copy, try the four questions above: fire the request concurrently, out of order, with an out-of-range value, and with another user's identifier. For any that succeed, implement the atomic or server-side fix and re-test. OWASP Juice Shop has several deliberate logic challenges (coupon and basket manipulation) to practise on legally.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.