~/learn / web-vulnerabilities / business-logic
Business Logic and Race Conditions
Vulnerabilities that come from flawed assumptions rather than malformed input, including workflow bypasses, numeric and quantity abuse, and the concurrency mechanics behind race conditions, plus how to design them out with atomic operations and idempotency.
Business logic flaws are bugs in the rules of an application, not in how it parses input. Every request is well-formed and passes validation, no scanner flags anything, yet the sequence or combination of requests produces an outcome the designers never intended. Finding these needs a human who understands what the application is for, which is exactly why they survive automated testing and reach production. They are also where the money is, because they map directly onto the thing of value the application protects.
Why they are invisible to tools
A scanner knows what a quote in a parameter might mean. It has no idea that your coupon should apply once, that a refund should not exceed the purchase, or that a user should not approve their own expense report. Those are facts about the domain, not about HTTP. The tester has to reconstruct the intended state machine, then look for edges the developer forgot to forbid.
Workflow and state-machine bypasses
Multi-step flows assume the steps happen in order. If the server does not enforce that, a user can skip, repeat, or reorder them.
- Step skipping: a checkout is cart, then shipping, then payment, then confirm. If the confirm endpoint does not re-verify that payment succeeded, requesting it directly completes the order unpaid. The fix is that each step verifies the prerequisites server-side, every time, rather than trusting that the client walked the happy path.
- Parameter tampering on trusted-looking fields: a price, a discount, a user id, or an
is_adminflag submitted by the client and accepted by the server. The classic is a hiddenpricefield in a form; the server should look up the price from its own database by product id and ignore any price the client sends. - Forced browsing and insecure direct object references:
GET /invoice/1001works, soGET /invoice/1002returns someone else’s invoice because the server checks authentication but not ownership. This is the most common serious finding in real applications. See testing access control for how to probe it methodically.
Numeric and quantity abuse
Logic that assumes positive, bounded, in-range numbers breaks when the assumption is not enforced.
- Negative quantities: a cart that accepts a quantity of
-1for a cheap item can subtract its price from the total, turning a purchase into a partial refund. Items priced at zero or fractional cents compound it. - Integer overflow and rounding: a quantity large enough to overflow a 32-bit total can wrap to a tiny or negative number. Currency math done in floating point loses cents that, aggregated or repeated, become real money moving the wrong way.
- Coupon and referral stacking: a discount intended to apply once applies repeatedly because the server never records that it was consumed, or because two different endpoints both apply it. Loyalty points, free trials, and “invite a friend” credits are the same shape.
The defense is explicit server-side invariants: quantities are integers within a sane range, prices and discounts come from the server, totals are computed in integer minor units (cents), and every single-use benefit is marked consumed in the same transaction that grants its effect.
Race conditions: the clock is an attacker
A race condition is a business logic flaw created by concurrency. The code checks a condition and then acts on it, assuming nothing changed in between. Two requests arriving close enough together both pass the check before either acts. This is a time-of-check to time-of-use gap, and on a multi-threaded, multi-process, multi-server backend the window is wide open.
The textbook case is a balance or a single-use voucher:
# Vulnerable: check and update are two separate steps
account = Account.find(id)
if account.balance >= amount # two requests both read 100
account.balance -= amount # both subtract 100
account.save # final balance 0, but 200 withdrawn
end
Fire the same request a hundred times in parallel and several slip through between the read and the write. Gift card and voucher systems have been drained exactly this way, and “redeem once” promotions turned into “redeem a thousand times” during the milliseconds before the first redemption committed.
The fix is to make the check and the act a single atomic operation that the database serializes for you.
# Safe: the database performs the check and the decrement in one atomic step,
# and the WHERE clause guarantees it only succeeds if funds remain
updated = Account.where(id: id).where("balance >= ?", amount)
.update_all("balance = balance - #{amount.to_i}")
raise InsufficientFunds if updated.zero?
Other reliable tools:
- Database-level locking:
SELECT ... FOR UPDATEinside a transaction takes a row lock so the second request waits for the first to commit. Correct, at the cost of contention. - Unique constraints as a guard: a unique index on
(user_id, coupon_id)makes a double-redeem fail at the database with a duplicate-key error, which is far more reliable than an application-level “have they used it” check. - Idempotency keys: require the client to send a unique key per logical operation, and record it. A retried or duplicated request with the same key is recognized and ignored, which also makes payment retries safe. This is how serious payment APIs prevent double charges.
- Optimistic locking: a version column that must match on write, so a stale update fails and retries.
Do not reach for an in-process mutex, a sleep, or a cache check. None of those coordinate across the many processes and servers that actually handle your traffic.
How to hunt for these
In an authorized test, map the intended flow first, then ask at every edge: what if I do this step twice, out of order, with a negative number, as a different user, or a hundred times at once. Tooling helps only to deliver the idea (for example firing many identical requests simultaneously to open a race window), never to find it. The finding comes from understanding the domain better than the person who built it.
Field notes: winning the race
Check-then-act bugs (apply a coupon once, withdraw within balance, redeem a gift card) are exploited by firing many requests so they all pass the check before any of them commits. The modern technique is the single-packet attack: Burp’s Turbo Intruder gathers 20 to 30 requests and releases them in one TCP packet (using HTTP/2 multiplexing or synchronized HTTP/1.1 framing) so they hit the server within microseconds, eliminating network jitter as a variable.
# Turbo Intruder concept: queue many identical requests, send the last byte of all at once
engine.queue(request) x30
engine.openGate() # release simultaneously
Real impact from a single race: redeem one gift card balance many times, apply a single-use discount repeatedly, bypass a “one vote / one signup / one withdrawal” limit, or over-draw an account because every thread read the old balance. If you see a limit enforced in application code rather than a database constraint, test it with a burst.
Field notes: parameter and workflow tampering
The clever logic findings come from asking “what did the developer assume the client could not change?”
- Trust-the-client price or quantity. Intercept checkout and set
price=0,amount=-1(a negative that credits you), or a quantity large enough to overflow the total back to a small number. Discount stacking: apply the same coupon twice, or a percentage and a fixed code together. - Skip a step. Multi-stage flows (cart, shipping, payment, confirm) often let you POST directly to the confirm endpoint with a crafted state, skipping payment. Force-browse to step 4’s URL after completing step 1.
- Change whose object you act on. The authorization is on “the current user,” but the target id is a parameter:
userId,accountId,orderId. This is where logic bugs meet IDOR. - Abuse state transitions. Cancel an order after it ships, re-open a closed ticket to gain access, or replay a “password reset complete” step. Map the state machine and try every transition that should be forbidden.
- Reuse one-time values. OTPs, reset tokens, and idempotency keys that are not invalidated on use can be replayed. Also test OTP endpoints for missing rate limits (brute-force a 4-digit code).
These bugs never show up in a scanner because every request is individually valid. They are found by understanding the business and deliberately breaking its assumptions.
Design checklist
- Enforce every workflow prerequisite server-side on every step, not just in the UI.
- Derive prices, roles, ownership, and limits from server state, never from client-submitted fields.
- Validate numeric ranges and compute money in integer minor units.
- Make every check-then-act on shared state atomic: a conditional update, a row lock, a unique constraint, or an idempotency key.
- Add object-level authorization (does this user own this record) to every endpoint that takes an id.
- Write abuse-case tests, not just happy-path tests. A test that fires fifty concurrent redemptions and asserts exactly one succeeds is worth more than any scanner here.