June 23, 2026
How a Race Condition in a Loyalty Wallet Scored a $15,000 Critical Payout
We’ve all seen standard web vulnerabilities like cross-site scripting (XSS) or SQL injection. But some of the most lucrative bugs don’t…

By Tanvi Chauhan
4 min read
We've all seen standard web vulnerabilities like cross-site scripting (XSS) or SQL injection. But some of the most lucrative bugs don't involve breaking code structure at all. Instead, they involve breaking time.
When an application processes actions sequentially, everything works perfectly. But when you force a server to process multiple actions at the exact same millisecond, the application's business logic can completely fracture. This is known as a Race Condition.
This is the story of how I targeted a global airline's rewards platform — let's call them GlobalAir — and turned a single $50 gift voucher into an infinite balance generator, resulting in a $15,000 bug bounty reward.
The Target: The Loyalty Point Wallet
GlobalAir has a massive frequent-flyer ecosystem. Users can earn points or redeem digital gift cards inside their "Loyalty Wallet." I focused my testing on their voucher redemption feature, which allowed users to input a 16-digit gift code to top up their account balance.
When you redeem a voucher, the application backend has to perform three critical steps in order:
- Check if the voucher code is valid and hasn't been used yet.
- Add the voucher's value (e.g., $50) to the user's account balance.
- Mark the voucher code as "spent" in the database so it can't be reused.
In a normal scenario, this sequence takes less than a second. But what happens if you send ten requests to redeem the same voucher at the exact same instant?
The Footprint: Analyzing the Request
I bought a legitimate $50 promotional voucher from their store to use as my testing baseline. I opened Burp Suite, turned on interception, and submitted the voucher code. The request was a straightforward HTTP POST:
HTTP
POST /api/v4/wallet/redeem HTTP/1.1
Host: loyalty.globalair.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
{
"voucher_code": "GA-2026-X992-L102"
}POST /api/v4/wallet/redeem HTTP/1.1
Host: loyalty.globalair.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
{
"voucher_code": "GA-2026-X992-L102"
}If I forwarded this request normally, the server responded with a success message, and my account balance updated by $50. If I tried to send the exact same request a second time, the database did its job perfectly:
JSON
{
"status": "error",
"message": "This voucher code has already been redeemed."
}{
"status": "error",
"message": "This voucher code has already been redeemed."
}To break this, I needed to exploit a synchronization gap — often called a "Time-of-Check to Time-of-Use" (TOCTOU) flaw. I needed Step 1 (the check) to occur for multiple requests before any of them could complete Step 3 (marking it spent).
The Twist: Turbo Intruder and HTTP/2 Multiplexing
Historically, executing a precise race condition across the internet was difficult because network latency naturally spaces out requests. However, modern HTTP/2 protocols support multiplexing, which allows a security researcher to pack multiple HTTP requests into a single network packet.
This means the server receives and unpacks all the requests at the absolute identical microsecond, completely neutralizing internet lag.
I sent the successful redemption request over to Turbo Intruder, a powerful Burp Suite extension designed for sending massive amounts of concurrent traffic. I configured a Python script inside Turbo Intruder to queue up 50 identical redemption requests using the exact same voucher code, utilizes the HTTP/2 single-packet attack technique.
The script mechanism looked like this:
Python
def queueRequests(target, wordlist):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=50,
pipeline=False)
# Gate the requests so they are all sent simultaneously
for i in range(50):
engine.queue(target.req, gate='race_gate')
engine.openGate('race_gate')def queueRequests(target, wordlist):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=50,
pipeline=False)
# Gate the requests so they are all sent simultaneously
for i in range(50):
engine.queue(target.req, gate='race_gate')
engine.openGate('race_gate')The Exploit: The Infinite Balance Multiplier
I set my test account balance to its starting state and kicked off the Turbo Intruder script.
Fifty requests containing the same single voucher code hit GlobalAir's API gateway, packed tightly inside the same network frame. The gateway unpacked them and distributed them across the backend microservices.
When the attack finished, I looked at the results table in Turbo Intruder.
Instead of getting one 200 OK and forty-nine 400 Bad Request errors, I saw six separate 200 OK responses before the remaining forty-four requests finally returned the "already redeemed" error.
I immediately opened my browser and refreshed my GlobalAir profile page.
My loyalty wallet balance didn't show $50. It showed $300.
Because the backend database lacked proper row-level locking, six independent server threads read the voucher status at the exact same time. All six threads checked Step 1, saw that the voucher was still "active," and proceeded to add $50 to my account balance. By the time the first thread completed Step 3 and flipped the status to "spent," five other threads had already slipped past the security check gate.
I had successfully duplicated money out of thin air using a single, low-value promotional code.
The Remediation and Payout
I stopped testing immediately to avoid altering real financial systems, documented the exact Turbo Intruder script, and submitted a detailed vulnerability report directly to GlobalAir's triage team.
- Submission: Friday, 11:00 AM
- Triaged as Critical (P1): Friday, 1:30 PM (Voucher redemption gateway temporarily paused)
- Fix Validated: Saturday, 9:00 AM
- Bounty Awarded: $15,000
GlobalAir patched the vulnerability by applying strict database transactions with pessimistic locking. Now, when a thread reads a voucher row to check its validity, it locks that specific row immediately (SELECT ... FOR UPDATE). Any concurrent requests attempting to read the same row are forced to wait in line until the first transaction fully completes its check, balance update, and status flip.
Core Lessons
- For Developers: Never trust application-level logic to handle concurrent state changes safely. When dealing with financial balances, inventory counts, or one-time tokens, always enforce database-level atomic operations or row-level locking to prevent parallel processing leaks.
- For Bug Hunters: Race conditions exist everywhere business logic updates numbers. Look for wallet redemptions, checkout flows, item transfers, or upvote loops. Don't rely on basic multi-threading tools; use modern HTTP/2 single-packet techniques to ensure your payloads hit the server engine simultaneously.
Have you ever found a race condition that completely broke an application's business logic? Let's talk about it in the comments below! If you enjoyed this write-up, leave some claps and follow along for more real-world bug bounty breakdowns.
𝒯𝒶𝓃𝓋𝒾 ♡