How I Doubled My Money on a Fintech Platform — A Race Condition Story
--
Hey there! I’m Olúwadémiládé — a Cyber Security Specialist and Software Engineer with a deep passion for breaking things before the wrong people do. When I’m not building systems, I’m tearing them apart to see what falls out, and trust me, things always fall out. Welcome to my fintech hack series, where I share real vulnerabilities I’ve uncovered — raw lessons straight from the trenches.
Let’s get into it.
In 2026, during a routine security assessment of a popular payment gateway, I stumbled onto something that made me sit up straight and stare at my screen for a good thirty seconds. I had just withdrawn ₦20,000 from an account that only had ₦10,000 in it. Both withdrawals went through. Both landed in my bank account. The platform said “Payout has been done successfully” — twice.
This is the story of how a race condition on a payout endpoint let me double my money, and why every fintech developer building payment systems needs to understand what went wrong here.
Disclaimer: This was conducted during an authorized penetration test. I had a signed contract with the company, and all findings were reported responsibly. The funds were accounted for and the vulnerability has since been addressed. I’m sharing this (without naming the platform) so others can learn from it.
Setting the Stage
The target was a payment gateway, the kind of platform where merchants plug in an API, collect payments from customers, and withdraw their earnings to a bank account. Think of it as the middleman between a business and the banking system.
My scope covered the merchant-facing API and the dashboard where merchants manage their funds, view transactions, and request payouts. Standard fintech infrastructure. I had a legitimate merchant account, Burp Suite running, and a cup of coffee. Nothing fancy.
I started by mapping out the endpoints, looking at how funds flow through the system. Deposit in, payout out. Simple enough. But the payout endpoint caught my attention almost immediately.
The Payout Endpoint
The platform had a manual payout feature. A merchant enters the amount they want to withdraw, confirms with their PIN, and the platform sends the money to their linked bank account. The request looked something like this:
POST /api/v1/payouts/manual-payout HTTP/2
Host: [REDACTED]
Content-Type: application/json
Authorization: Bearer [REDACTED]{
"amount": 10000,
"pin": "[REDACTED]",
"currency": "NGN",
"processingFee": "15"
}Clean. Simple. Nothing suspicious on the surface. But I had a question: what happens if I send this request more than once at the exact same time?
The First Test — ₦500
I didn’t go wild immediately. I started small.
My account balance was ₦972. I crafted a withdrawal request for ₦500 and loaded it into Burp Suite’s Repeater. Then I duplicated the tab about 15 times and used Burp’s “Send group (parallel)” feature — which fires all the requests simultaneously in a single burst.
I hit send and watched the responses roll in.
Most of them failed. Some returned 500 errors. But two of them came back with HTTP 200:
{
"requestSuccessful": true,
"responseCode": "success",
"responseMessage": "Payout has been done successfully"
}Two successful withdrawals of ₦500. From an account with ₦972. That’s ₦1,000 going out when only one withdrawal should have been possible.
I checked my bank account. ₦1,000 credited.
I checked the platform dashboard. My balance showed ₦472 — meaning the system only registered one deduction internally, but the banking system had already processed two payouts.
At this point I knew exactly what I was looking at: a Time-of-Check to Time-of-Use flaw. But I needed to confirm it cleanly.
The Confirmation — ₦10,000
I deposited a fresh ₦10,000 into my merchant wallet. Clean balance. No ambiguity.
This time I set up 100 parallel requests in Burp Suite, each requesting a full ₦10,000 withdrawal. Same endpoint, same payload, same PIN. The only difference was that all 100 requests would hit the server at the exact same moment.
I fired them off.
Out of 100 requests:
- 2 returned HTTP 200 — payout successful
- Several returned HTTP 400 with a database error
- The rest returned HTTP 500 — server error
The two successful responses meant ₦20,000 in payouts from a ₦10,000 balance.
I opened my banking app. ₦20,000 credited. Two separate transactions of ₦10,000 each, both from the payment platform.
I had just doubled my money.
So What Actually Happened?
The vulnerability is called a race condition, specifically a TOCTOU (Time-of-Check to Time-of-Use) flaw. Here’s how it works in plain language:
When you request a payout, the server does two things:
- Check — “Does this merchant have enough balance?”
- Deduct — “Okay, subtract the amount and send the money.”
The problem is that these two steps aren’t atomic — they don’t happen as one indivisible operation. There’s a tiny gap between the check and the deduct. Normally, that gap is so small it doesn’t matter. But when you send 100 requests at the same time, multiple requests slip into that gap simultaneously.
Here’s what happens:
Request A reads balance → ₦10,000 ✓ (enough funds)
Request B reads balance → ₦10,000 ✓ (enough funds)
Request A deducts ₦10,000 → balance is now ₦0
Request B deducts ₦10,000 → balance is now -₦10,000Both requests passed the balance check because they both read the balance before either one had deducted from it. By the time the server realizes what happened, the money is already gone.
The platform actually had a database lock mechanism in place — I could tell because some of the failed requests returned a MySQL deadlock error:
SQLSTATE[40001] Serialization failure: 1213 Deadlock found
when trying to get lockSo they tried to prevent this. But the lock wasn’t implemented correctly. It wasn’t wrapping the check-and-deduct in a single atomic operation, so the race window still existed.
Bonus: The Error Messages Were Talking Too Much
That deadlock error I mentioned? It wasn’t just telling me the lock failed. The full error response leaked:
- The database engine (MySQL)
- The table name (
wallets) - Column names (
available_balance,updated_at) - A specific wallet record ID
This is a textbook information disclosure vulnerability. If someone were looking for SQL injection opportunities on other endpoints, this error just handed them the database schema on a silver platter. Always sanitize your error responses in production.
How This Should Have Been Built
The fix for this is well-known in backend engineering, but I see fintech platforms get it wrong constantly. Here’s the approach that eliminates the race condition entirely:
Atomic UPDATE — check and deduct in one SQL statement:
UPDATE wallets
SET available_balance = available_balance - :amount
WHERE id = :wallet_id
AND available_balance >= :amount;If the balance is sufficient, the deduction happens. If not, zero rows are affected and you reject the payout. There is no gap between checking and deducting because it’s a single operation. No race window. Done.
For extra safety, you can layer on:
- Distributed locking (e.g., Redis lock per wallet) so only one payout can process per user at any time
- Idempotency keys so the same withdrawal can’t be submitted twice
- A sequential queue for all financial transactions per wallet — architecturally bulletproof
- Stricter per-user rate limiting on payout endpoints specifically, not just global API rate limits
The Aftermath
I compiled my findings, wrote a detailed report, and sent it to the company. They responded quickly and addressed the vulnerability. I’m not naming the platform — I was under contract and the goal was always to help them fix it, not to cause damage.
But I’m sharing this because race conditions on financial endpoints are everywhere. If you’re building a system that handles money — whether it’s a payment gateway, a banking app, a crypto exchange, or even a loyalty rewards system — and your balance check and deduction aren’t atomic, you are vulnerable.
The attack requires nothing exotic. A valid account, Burp Suite (or even a simple Python script with asyncio), and a few seconds of patience. That's it.
Key Takeaways
- Race conditions aren’t theoretical. Real money moved. ₦20,000 from a ₦10,000 balance, confirmed in a live bank account.
- Having a lock isn’t enough. The platform had database locking. It still didn’t work because the lock didn’t cover the full check-and-deduct operation atomically.
- Your error messages are a vulnerability. The deadlock error leaked the entire database schema. Sanitize everything that faces the internet.
- Every financial endpoint needs this audit. Not just withdrawals — transfers, purchases, refunds, rewards. Anywhere money moves, test for races.
- Parallel request testing should be standard. Tools like Burp Suite’s “Send group (parallel)” and Turbo Intruder make this trivial. If your QA team isn’t testing for concurrency, your pentesters (or attackers) will.
If you enjoyed this and want to see more breakdowns of real-world vulnerabilities I’ve found during authorized or grey hacking engagements, follow me here on Medium. This is just the first in the series — there’s plenty more where this came from.
For those just meeting me, I’m Olúwadémiládé — a Software Engineer and CyberSec Specialist who spends his free time poking holes in fintech platforms (ethically, of course). I believe the best way to build secure systems is to understand exactly how they break.
Stay safe, and always test your locks.
#SecurityIsAnIllusion.