493 words
2 minutes
Uncovering a Race Condition in Payment Processing

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 Membership

Simple 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:

  1. The donation page starts a transaction and runs SELECT donation, which pulls a snapshot showing status=0.

  2. Even if the webhook finishes processing and commits status=1 a millisecond later, the donation page is still looking at its original snapshot where status=0.

  3. Because both connections see status=0, they both think they need to create a membership. They both run INSERT, and both successfully COMMIT.

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

  1. Create a test account and log in.

  2. Initiate a donation and note the <donationId> from the URL.

  3. Send the money and close the donation page.

  4. If the donation was using cryptocurrency, wait for blockchain confirmations.

  5. 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)
  1. 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.

  2. 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 resources

Aftermatch

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.