
How I earned $2,000 discovering a time-of-check to time-of-use vulnerability
Introduction
Sometimes the most rewarding discoveries aren’t about finding complex zero-days, but rather catching fundamental flaws in how systems handle concurrency. Recently, I submitted a vulnerability report that resulted in a $2,000 payout – my largest to date. It wasn’t advanced cryptography or chain exploits. It was a classic race condition in a payment processing flow.
The Flow
User Donates → Webhook Confirms Payment → User Refreshes Page → System Grants MembershipSimple enough. But the problem emerged in how the system checked the donation status before granting membership access.
Why the Race Works
The root of the issue comes down to MariaDB’s default REPEATABLE-READ isolation level. Under this setting, a transaction locks in a snapshot of the database the moment it executes its first read.
Here is exactly how the collision happens:
The donation page starts a transaction and runs
SELECT donation, which pulls a snapshot showingstatus=0.Even if the webhook finishes processing and commits
status=1a millisecond later, the donation page is still looking at its original snapshot wherestatus=0.Because both connections see
status=0, they both think they need to create a membership. They both runINSERT, and both successfullyCOMMIT.
With proper timing and concurrent requests, one payment could unlock multiple memberships.
Steps to Reproduce
What You Need
- A test account on the site.
- A small amount of money to pay for the membership.
- A tool capable of sending multiple concurrent HTTP requests. I used Burp Suite with Turbo Intruder extension.
Procedure
Create a test account and log in.
Initiate a donation and note the
<donationId>from the URL.Send the money and close the donation page.
If the donation was using cryptocurrency, wait for blockchain confirmations.
Prepare Burp Suite for the race
a. Find any GET request to the site that includes the account cookie
b. Right-click → Send to Turbo Intruder
c. Modify the request in Turbo Intruder with the donation ID and the account cookie
d. I used this Turbo Intruder script to run the requests simultaneously:
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=8, # 8 concurrent connections
engine=Engine.BURP2
)
# Send 8 requests simultaneously
for i in range(8):
engine.queue(target.req, gate='race')
# Release all at once (last-byte sync)
engine.openGate('race')
def handleResponse(req, interesting):
table.add(req)Execute the race
a. When you estimate the blockchain transaction should be confirmed, click Attack in Turbo Intruder.
b. In this particular script, all 8 requests fire simultaneously.Repeat if needed
Remediation Recommendation
Based on standard industry practices for this vulnerability class, here’s what typically resolves race conditions in payment systems:
Database-level locking
Implement row-level locks on initial reads
Use SELECT ... FOR UPDATE where appropriate
Unique constraints
Add database constraints preventing duplicate records
Enforce one-membership-per-payment relationship at the schema level
Idempotency checks
Ensure processing the same event multiple times has no effect
Track which payments have already been converted to memberships
Serialized processing
Consider queue-based processing instead of direct request handling
Use distributed locks for shared resourcesAftermatch
The program’s responsiveness impressed me throughout the process. In less than 24 hours they validated the issue, fixed it, sent me the bounty and asked me to test it again.
