September 6, 2026
Winning a Race Against a Wallet: How I Turned a $50 Top-Up Into $2,000 of Free Balance ($6,000…
Some vulnerability classes get all the attention — SQL injection, XSS, SSRF — while race conditions quietly sit in the background…

By T4nv1
7 min read
Some vulnerability classes get all the attention — SQL injection, XSS, SSRF — while race conditions quietly sit in the background, underreported and underestimated by a huge chunk of the hunting community. That's a mistake, because race conditions are consistently some of the highest-paying bugs I've found, and they're often hiding in plain sight in the most "obviously correct" parts of an application: payment flows, coupon redemption, wallet top-ups, and inventory systems.
This is the story of a race condition I found in a fintech-adjacent platform's wallet top-up flow, which let me multiply a single $50 payment into roughly $2,000 of usable balance. I'm writing this one up in more depth than usual because race conditions are conceptually simple but genuinely tricky to execute reliably, and most beginner-level writeups skip the part that actually matters: the tooling and timing precision needed to win the race consistently rather than by luck.
The Target and the Feature
I'll call the platform paylo.app (name redacted per program policy). It's a marketplace app where users maintain an internal wallet balance, top it up via card payment, and spend it on purchases within the platform. The top-up flow looked, from the outside, completely unremarkable:
- User selects an amount to add to their wallet.
- A payment intent is created with a third-party processor (Stripe, in this case).
- Once the payment succeeds, the client calls a confirmation endpoint.
- The confirmation endpoint credits the user's wallet balance and marks the payment as processed.
Step four is where I focused my attention, because "credit balance, then mark as processed" is a two-part operation, and the order and atomicity of those two parts determine whether the system is vulnerable.
Why Race Conditions Live in This Exact Pattern
Almost every race condition I've ever found follows the same underlying shape: the application checks a condition, then acts on it, and there's a window of time between the check and the action where the state hasn't been finalized yet. In pseudocode, the vulnerable logic usually looks something like this:
def confirm_payment(payment_id, user_id, amount):
payment = get_payment(payment_id)
if payment.status != "processed":
credit_wallet(user_id, amount)
payment.status = "processed"
save(payment)def confirm_payment(payment_id, user_id, amount):
payment = get_payment(payment_id)
if payment.status != "processed":
credit_wallet(user_id, amount)
payment.status = "processed"
save(payment)If two requests hit confirm_payment for the same payment_id close enough together, both can read payment.status as "not processed" before either one finishes writing the update. Both credit the wallet. Both mark it processed — the second write just overwrites the first with the same value, so nothing looks wrong in the database afterward. The wallet ends up credited twice for a single real-world payment, and there's no error, no failed request, and no obvious trace unless someone specifically audits payment-to-credit ratios.
This is sometimes called a Time-Of-Check-To-Time-Of-Use (TOCTOU) issue, and it's the textbook root cause behind almost every "redeem a coupon twice" or "top up your wallet twice" bug bounty report you'll find.
Step One: Confirming the Endpoint Was Reachable Multiple Times
Before attempting a real concurrency attack, I wanted to confirm the endpoint didn't already have obvious protections like idempotency keys or a database-level unique constraint. I made a legitimate $50 top-up, captured the confirmation request in Burp Suite, and simply replayed it a few seconds apart using Repeater:
POST /api/wallet/confirm HTTP/1.1
Host: paylo.app
Authorization: Bearer <token>
Content-Type: application/json
{
"payment_id": "pi_3Oa8x2K...",
"amount": 50.00
}POST /api/wallet/confirm HTTP/1.1
Host: paylo.app
Authorization: Bearer <token>
Content-Type: application/json
{
"payment_id": "pi_3Oa8x2K...",
"amount": 50.00
}The first replay returned a 409 Conflict with a message indicating the payment was already processed. That told me there was a status check in place — sequential replays wouldn't work. But a check that runs correctly when requests are seconds apart tells you nothing about what happens when they arrive within milliseconds of each other. Sequential testing only proves the check exists; it says nothing about whether the check and the write are atomic. That's the gap race condition testing is designed to find.
Step Two: Building a Real Concurrency Test
Manually double-clicking "send" in Burp isn't precise enough for this. Network jitter alone introduces enough delay that two manually-fired requests rarely land close enough together to actually collide inside the vulnerable window, which is often just a handful of milliseconds. I needed the requests to arrive at the server as close to simultaneously as possible.
For this, I used Turbo Intruder, a Burp extension purpose-built for high-precision timing attacks. The core trick is to open all the required TCP connections and hold the final bytes of each request back, then release every request's last byte at the same instant — a technique often called "last-byte synchronization." This minimizes the network-level jitter between requests, which is the main variable working against you in a race condition attempt.
A simplified version of the script looked like this:
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=20,
requestsPerConnection=1,
pipeline=False
)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
engine.complete(timeout=60)
def handleResponse(req, interesting):
table.add(req)def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=20,
requestsPerConnection=1,
pipeline=False
)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
engine.complete(timeout=60)
def handleResponse(req, interesting):
table.add(req)The gate mechanism is the important part — it queues all 20 requests but holds them at the gate until openGate fires, releasing them together instead of one after another. I pointed all 20 requests at the same payment_id from the single legitimate $50 top-up I'd made earlier.
The Result
Out of 20 near-simultaneous requests, 14 returned a successful 200 OK response, each independently crediting $50 to my wallet. The other 6 returned the expected 409 Conflict. Checking my wallet balance afterward confirmed it: a single $50 charge on my card had produced $700 of wallet balance from that one batch alone.
That already would have been a strong finding on its own, but I wanted to understand the actual ceiling of the bug, since programs generally reward based on realistic worst-case impact, not just what you personally managed to trigger. I reran the same test with 40 concurrent requests instead of 20, and got 31 successful credits — suggesting the vulnerable window wasn't a fixed size but scaled roughly with how many requests I threw at it, up to whatever concurrency limit the backend infrastructure could handle. At that rate, a single real payment of any size could theoretically be multiplied dozens of times over, limited mostly by how aggressively an attacker was willing to hammer the endpoint before it got noticed.
Across a few test runs at different amounts, I accumulated a little over $2,000 of wallet balance from what should have been a total of $150 in real charges. I stopped there — the point of testing severity is to demonstrate the shape and scale of the impact, not to actually accumulate spendable value on a production account, so I didn't attempt to use any of the inflated balance to make real purchases.
Why the Fix Isn't Just "Add a Mutex"
The obvious naive fix — wrap the whole function in an application-level lock — often doesn't actually solve this in distributed systems, because most modern backends run multiple application server instances behind a load balancer. A lock held in one process's memory does nothing to stop a concurrent request landing on a different instance. The fix has to happen at a layer all instances share:
- Database-level atomicity: use an atomic conditional update (
UPDATE payments SET status = 'processed' WHERE id = ? AND status != 'processed') and only proceed with crediting the wallet if the update actually affected a row. This closes the TOCTOU gap because the check and the write happen as a single atomic database operation instead of two separate application-level steps. - Idempotency keys: require a unique, single-use key per payment confirmation attempt, enforced with a unique constraint at the database level, so a duplicate request fails at the constraint rather than racing through application logic.
- Distributed locking: for cases where atomic updates alone aren't enough, a distributed lock (Redis-based, for example) shared across all application instances can serialize access to the critical section.
I included all three of these as remediation options in my report, since different teams have different appetites for how much of their transaction logic they're willing to restructure.
Structuring the Report for Maximum Clarity
Race condition reports live or die on reproducibility, because triagers who aren't used to timing attacks will sometimes struggle to replicate them with a normal browser or a simple curl loop. I made sure to include:
- A plain-language summary stating the impact first: wallet balance can be duplicated an arbitrary number of times from a single real payment.
- The exact Turbo Intruder script used, so the triage team could reproduce it without having to reverse-engineer my methodology.
- A screen recording showing the wallet balance before the attack, the batch of requests firing, and the balance immediately afterward.
- A clear statement of what I did and didn't do — specifically that I never attempted to spend the inflated balance on real purchases, to avoid any ambiguity about whether I'd caused actual financial harm to the platform.
- The three remediation options above, ranked by how thoroughly each one closes the gap.
Timeline
- Day 0 — Report submitted with Turbo Intruder script and screen recording
- Day 2 — Triaged after the security team reproduced it internally, marked Critical (financial impact, no rate limit)
- Day 5 — Idempotency key requirement added to the confirmation endpoint as an emergency mitigation
- Day 19 — Full fix deployed: atomic conditional update plus idempotency key enforcement
- Day 24 — Bounty awarded: $6,000
- Day 60 — Public disclosure approved
What This Bug Taught Me About Hunting Race Conditions
The single biggest lesson from this hunt is that sequential testing tells you almost nothing about race condition vulnerability. I could have replayed that confirmation request a hundred times, one after another, and concluded the endpoint was safe — because it is safe against sequential replay. The vulnerability only exists in a window measured in milliseconds, and finding it requires tooling built specifically to minimize timing jitter, not just "clicking fast."
If you're hunting for race conditions, the checklist I run through on every target now is:
- Any endpoint that checks a limited resource before acting on it (coupon codes, wallet balances, inventory counts, invite quotas, rate-limited actions)
- Any multi-step flow where a status check and a status update happen as separate operations rather than one atomic one
- Anything tied to money, since that's where severity — and bounty payouts — climb the fastest
Race conditions won't show up in an automated scanner's report. They require you to actually understand the business logic well enough to know what "doing this twice" would mean for the platform, and then build the tooling to test it properly. That combination of manual insight and technical precision is exactly why these bugs pay so well when you find them.