Back to the blog
Vulnerability11 min readUpdated

Race Condition in Payments: The Double-Refund Bug

Race conditions in refund endpoints are the most underrated fintech bug. A technical breakdown of the HTTP/2 single-packet attack on financial endpoints.

Diego Melo, author
Diego Melo

Security researcher and founder of No Vuln

Race Condition in Payments: The Double-Refund Bug

A race condition in Pix endpoints is the most underrated bug in Brazilian fintech in 2026. Pix is Brazil's instant payment system, run by the Central Bank of Brazil (BACEN). This isn't theoretical. It's in production in nearly every fintech that hasn't had a pentest focused on HTTP/2 race conditions. When exploited, it moves real money from the bank to the attacker's account. The technical pattern applies to any instant payment system.

This post is a technical breakdown of the pattern, with an anonymized exploitation flow and a remediation checklist. If you run a fintech, an authorized payment institution, a payment gateway or any system that processes financial flows with a refund, chargeback, withdrawal or reversal endpoint — this is required reading.

The bug in 1 sentence

An endpoint that processes a refund (or a withdrawal, or a reversal) without an atomic lock accepts multiple parallel executions of the same request, moving money more than once before it marks the request as “already processed.”

Why the HTTP/2 single-packet attack changed the game

Race conditions have always existed. What changed in 2020+ was the technical ability to fire dozens of requests in the same TCP packet over HTTP/2.

Pre-HTTP/2: the client sends request 1, waits for the ACK, sends request 2. Network latency between requests was ~50-200ms. A race condition needs a microsecond window to be exploitable in practice, so they used to be rare.

Post-HTTP/2 + multiplexing + single-packet attack: the client sends 30 requests in the SAME TCP packet. The server processes them almost simultaneously. The window between requests: microseconds. A race condition that was theoretical becomes trivially exploitable.

Tools like Turbo Intruder (a Burp Pro extension) implement this natively. It's not an obscure technique — it's standard practice in modern pentesting.

The typical vulnerable endpoint

A classic Pix refund endpoint:

POST /api/v1/pix/transactions/{tx_id}/refund
Authorization: Bearer <token>

Naive implementation (pseudo-code):

  1. Load the transaction: transaction = Transaction.get(tx_id)
  2. Check: if transaction.status == 'refunded': raise AlreadyRefundedError()
  3. ← race window here ←
  4. Transfer: bank_api.transfer_back(payer, amount)
  5. Update the status: transaction.status = 'refunded'; transaction.save()

Between the if status == 'refunded' check and the transaction.save() there's a window where another parallel request passes the same check.

Result: 30 simultaneous requests → 30 bank transfers while the request is still marked as “not refunded yet.”

The exploitation in practice

Using Turbo Intruder (a Python script in Burp Pro):

  • Configure the engine with requestsPerConnection=30 and engine=Engine.BURP2
  • Queue 30 identical POST requests with gate='race1'
  • Open the gate: engine.openGate('race1') — releases them all simultaneously
  • Observe the responses

Result in a vulnerable fintech:

  • 1 request: 200 OK (refund processed)
  • 29 requests: 200 OK as well (all processed)
  • The “customer's” bank account received 30x R$ 1,000 = R$ 30,000
  • Transaction status: refunded (once in the DB)

The attacker spends R$ 1,000 on a purchase, refunds it, and gets R$ 30,000 back. It's not theoretical — it's the exact pattern we keep seeing.

Where this shows up in fintech

It's not just Pix. In pentests, we've found race conditions in:

Financial endpoints

  • Pix refund (the scenario above)
  • Card chargeback
  • Balance withdrawal
  • Receivables advance
  • Investment redemption

“Limit” endpoints

  • Single-use coupon (a R$ 100 coupon used 30x = R$ 3,000 in discount)
  • Cashback (a race on cancellation = doubled cashback)
  • Promo voucher (a “100 vouchers” limit turned into 130)

Authentication endpoints

  • MFA bypass (a race on OTP code validation)
  • Rate-limit bypass (30 requests before the counter increments)
  • Account recovery race

State endpoints

  • Transfer approval (approve it 2x before it becomes “approved”)
  • Order confirmation (negative stock)
  • Plan change in SaaS (downgrade after billing, double upgrade)

How to fix it — wrong solutions vs correct ones

❌ Wrong solution 1: time.sleep()

Adding time.sleep(0.1) between the check and the write doesn't fix it. It only shrinks the window. Trivially bypassed with more requests or a more aggressive tool.

❌ Wrong solution 2: an in-memory Python lock

Using threading.Lock() or similar doesn't fix it in production. A lock only works within the same process. In production with multiple workers/instances (gunicorn, uwsgi, kubernetes), each process has its own lock. The race across processes continues.

✅ Correct solution 1: an atomic lock in the database (PostgreSQL SELECT FOR UPDATE)

Inside a database transaction, using SELECT FOR UPDATE locks the row in PostgreSQL. Other requests wait until the first one commits or rolls back. Pseudo-flow:

  1. Open a DB transaction
  2. transaction = Transaction.select_for_update(tx_id) — acquires the lock
  3. Check the status
  4. Transfer the money
  5. Update the status and save
  6. Commit (the lock is released automatically)

It works in a distributed environment (all workers share the same DB). It's the minimum baseline for any financial endpoint.

✅ Correct solution 2: Idempotency Key (the Stripe / Adyen / Mercado Pago pattern)

The client sends a Idempotency-Key: <unique-uuid-per-operation> header. The server:

  1. Checks whether the key has already been processed (a lookup in a table or cache)
  2. If it has: returns the cached response (doesn't reprocess)
  3. If it hasn't: creates a record with a lock, processes, saves the response, marks it complete
  4. On retries or races: the key exists → the cached response is returned

This is the pattern used by Stripe, Adyen, Mercado Pago and any serious acquirer. It's not optional in a serious fintech — it's mandatory.

✅ Correct solution 3: a distributed lock (Redis)

When the logic crosses multiple services (microservices) and a DB lock doesn't cover it, use a distributed Redis lock (the Redlock pattern or similar). It locks on a logical resource, not on the DB.

The 3 tests every fintech should have in CI

Test 1: refund not duplicated under a race

Create 1 transaction, fire 30 parallel requests via threading, expect assert success_count == 1. If more than 1 passes, the race is open.

Test 2: idempotency key enforced

Make a request without the Idempotency-Key header, expect assert response.status == 400. Ensures the financial endpoint rejects requests without idempotency.

Test 3: same idempotency key = same response

Make 2 requests with the same key, compare the responses, expect assert r1.json() == r2.json() and assert get_processed_count() == 1.

A CI that fails if the race is reintroduced. This is the definitive preventive control.

The cost of not fixing it

Real cases we've seen (anonymized):

Vulnerable endpointMaximum impact if exploited
Pix refundA bank transfer of 30x the original amount
Single-use 50% off couponCoupon used N times across parallel orders
Balance withdrawalWithdrawal larger than the available balance (negative balance)
MFA OTP2FA bypass (a race between check and expiration)
Card chargebackCredit for N chargebacks from a single transaction

In a BACEN-regulated fintech, this bug:

  • Violates the Pix Security Manual (operational controls)
  • Creates a reportable incident (BCB Resolution 1/2020)
  • Has a direct and immediate financial impact (not a potential one)
  • Can trigger an unscheduled audit if it recurs

What to do today

If you run a fintech, a gateway, a payment institution, or any system with financial endpoints:

  1. List every endpoint that changes a balance, status or counter
  2. For each one, verify: is there an atomic lock? An idempotency key?
  3. Test it manually with Turbo Intruder (30 parallel requests)
  4. Add race tests to CI (templates above)
  5. If you're not sure: commission a pentest focused on race conditions

Most of the Brazilian fintechs we audited in 2026 have at least 1 vulnerable endpoint. Not because the team is bad — because HTTP/2 race conditions changed completely over the last 4 years, and a lot of old docs/blogs still recommend insufficient patterns.

To talk about a pentest focused on Pix and financial flows, see the dedicated pages: Pix security testing, fintech penetration testing and payments penetration testing. To understand other flaws that rule APIs in 2026, see BOLA, BOPLA and BFLA.

Share

WhatsAppLinkedInX

Next step

Want to apply this to your system?

No Vuln runs in-depth penetration tests with the same methodology described in this article. Request a proposal: mutual NDA within 24h, scope defined on a technical call.