Volver al blog
Vulnerabilidad11 min de lecturaActualizado el

Race condition en Pix: el bug que paga el reembolso 2 veces

El race condition en reembolsos Pix es el bug más subestimado en fintech en 2026. Análisis del HTTP/2 single-packet attack en endpoints financieros.

Diego Melo, autor
Diego Melo

Investigador de seguridad y fundador de No Vuln

Race condition en Pix: el bug que paga el reembolso 2 veces

El race condition en endpoints de Pix — el sistema de pagos instantáneos del Banco Central de Brasil — es el bug más subestimado de las fintechs brasileñas en 2026. No es teórico. Está en producción en casi toda fintech que no pasó por un pentest enfocado en race conditions de HTTP/2. Cuando se explota, transfiere dinero real del banco a la cuenta del atacante.

La parte técnica vale para cualquier sistema de pagos instantáneos — SPEI en México, Bre-B en Colombia —; los ejemplos y la regulación citada son de Brasil, sin promesa de conformidad local en otros países.

Este post es el análisis técnico del patrón, con un flujo de explotación anonimizado y un checklist de corrección. Si operas una fintech, una IP autorizada, un gateway de pago o cualquier sistema que procesa flujo financiero con un endpoint de devolución, refund o retiro — es lectura obligatoria.

El bug en 1 frase

Un endpoint que procesa una devolución (o un retiro, o un reembolso) sin un lock atómico acepta múltiples ejecuciones paralelas del mismo pedido, transfiriendo dinero más de una vez antes de marcar el pedido como “ya procesado”.

Por qué el HTTP/2 single-packet attack cambió el juego

Los race conditions siempre existieron. Lo que cambió desde 2020 fue la capacidad técnica de disparar decenas de requests en el mismo paquete TCP vía HTTP/2.

Pre-HTTP/2: el cliente envía el request 1, espera el ACK, envía el request 2. La latencia de red entre requests era de ~50-200ms. Un race condition exige una ventana de microsegundos para explotarse de verdad. En la práctica, raros.

Post-HTTP/2 + multiplexing + single-packet attack: el cliente envía 30 requests en el MISMO paquete TCP. El servidor los procesa casi simultáneamente. Ventana entre requests: microsegundos. El race condition que era teórico se vuelve explotable de forma trivial.

Herramientas como Turbo Intruder (extensión de Burp Pro) lo implementan de forma nativa. No es una técnica oscura — es práctica estándar en el pentest moderno.

El endpoint vulnerable típico

Endpoint clásico de devolución Pix:

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

Implementación ingenua (pseudocódigo):

  1. Carga la transacción: transaction = Transaction.get(tx_id)
  2. Chequea: if transaction.status == 'refunded': raise AlreadyRefundedError()
  3. ← ventana de race aquí ←
  4. Transfiere: bank_api.transfer_back(payer, amount)
  5. Actualiza el status: transaction.status = 'refunded'; transaction.save()

Entre el check if status == 'refunded' y el transaction.save() hay una ventana donde otro request paralelo pasa por el mismo check.

Resultado: 30 requests simultáneos → 30 transferencias bancarias mientras el pedido queda marcado como “todavía no devuelto”.

La explotación en la práctica

Usando Turbo Intruder (script Python en Burp Pro):

  • Configurar el engine con requestsPerConnection=30 y engine=Engine.BURP2
  • Encolar 30 requests POST idénticos con gate='race1'
  • Abrir el gate: engine.openGate('race1') — los libera todos simultáneamente
  • Observar las responses

Resultado en una fintech vulnerable:

  • 1 request: 200 OK (devolución procesada)
  • 29 requests: 200 OK también (todos procesaron)
  • La cuenta bancaria del “cliente” recibió 30x R$ 1.000 = R$ 30.000
  • Status de la transacción: refunded (1 vez en la DB)

El atacante gasta R$ 1.000 en una compra, la devuelve, recibe R$ 30.000 de vuelta. No es teórico — es el patrón exacto que vemos repetirse.

Dónde aparece esto en las fintechs

No es solo Pix. En pentest, encontramos race condition en:

Endpoints financieros

  • Devolución Pix (el escenario de arriba)
  • Reembolso de tarjeta
  • Retiro de saldo
  • Adelanto de cobros
  • Rescate de una inversión

Endpoints de “límite”

  • Cupón single-use (un cupón de R$ 100 usado 30x = R$ 3.000 de descuento)
  • Cashback (race en la cancelación = cashback duplicado)
  • Voucher de promoción (el límite de “100 vouchers” se volvió 130)

Endpoints de autenticación

  • MFA bypass (race en la validación del código OTP)
  • Bypass de rate limit (30 requests antes de que el counter incremente)
  • Account recovery race

Endpoints de estado

  • Aprobación de transferencia (aprobar 2x antes de que quede “aprobada”)
  • Confirmación de pedido (stock negativo)
  • Cambio de plan en un SaaS (downgrade tras el cobro, doble upgrade)

Cómo corregirlo — soluciones equivocadas vs correctas

❌ Solución equivocada 1: time.sleep()

Agregar time.sleep(0.1) entre el check y el write no lo resuelve. Solo reduce la ventana. Se sortea de forma trivial con más requests o con una herramienta más agresiva.

❌ Solución equivocada 2: lock en memoria de Python

Usar threading.Lock() o similar no lo resuelve en producción. El lock solo funciona dentro del mismo proceso. En producción con múltiples workers/instancias (gunicorn, uwsgi, kubernetes), cada proceso tiene su propio lock. El race entre procesos continúa.

✅ Solución correcta 1: lock atómico en la base de datos (PostgreSQL SELECT FOR UPDATE)

Dentro de una transacción de la base de datos, usar SELECT FOR UPDATE bloquea la fila en PostgreSQL. Los demás requests esperan hasta el commit o rollback del primero. Pseudo-flujo:

  1. Abrir la transacción de la DB
  2. transaction = Transaction.select_for_update(tx_id) — adquiere el lock
  3. Chequear el status
  4. Transferir el dinero
  5. Actualizar el status y guardar
  6. Commit (el lock se libera automáticamente)

Funciona en un entorno distribuido (todos los workers comparten la misma DB). Es el baseline mínimo para cualquier endpoint financiero.

✅ Solución correcta 2: Idempotency Key (patrón Stripe / Adyen / Mercado Pago)

El cliente envía el header Idempotency-Key: <uuid-único-por-operación>. El servidor:

  1. Verifica si la clave ya se procesó (consulta en una tabla o cache)
  2. Si ya se procesó: devuelve la respuesta cacheada (no reprocesa)
  3. Si no: crea el registro con lock, procesa, guarda la respuesta, la marca como completa
  4. En retries o races: la clave existe → se devuelve la respuesta cacheada

Este es el patrón que usan Stripe, Adyen, Mercado Pago y cualquier adquirente serio. No es opcional en una fintech seria — es obligatorio.

✅ Solución correcta 3: lock distribuido (Redis)

Cuando la lógica atraviesa múltiples servicios (microservices) y el lock de la DB no cubre, usa un lock distribuido con Redis (patrón Redlock o similar). Bloquea sobre un recurso lógico, no sobre la DB.

Las 3 pruebas que toda fintech debería tener en el CI

Prueba 1: reembolso no duplicado bajo race

Crea 1 transacción, dispara 30 requests paralelos vía threading, espera assert success_count == 1. Si pasa más de 1, race abierto.

Prueba 2: idempotency key enforced

Hace un request sin el header Idempotency-Key, espera assert response.status == 400. Garantiza que el endpoint financiero rechaza los requests sin idempotency.

Prueba 3: misma idempotency key = misma respuesta

Hace 2 requests con la misma key, compara las responses, espera assert r1.json() == r2.json() y assert get_processed_count() == 1.

Un CI que falla si se reintroduce el race. Ese es el control preventivo definitivo.

El costo de no corregirlo

Casos reales que vimos (anonimizados):

Endpoint vulnerableImpacto máximo si se explota
Devolución PixTransferencia bancaria 30x del monto original
Cupón single-use 50% offCupón usado N veces en pedidos paralelos
Retiro de saldoRetiro mayor que el saldo disponible (saldo negativo)
OTP de MFABypass de 2FA (race entre el check y la expiración)
Reembolso de tarjetaCrédito de N reembolsos vía 1 transacción

En una fintech regulada por el BACEN (el Banco Central de Brasil), este bug:

  • Viola el Manual de Seguridad de Pix (controles operacionales)
  • Genera un incidente reportable (Resolución BCB 1/2020)
  • Tiene un impacto financiero directo e inmediato (no potencial)
  • Puede disparar una auditoría no programada si es reincidente

Qué hacer hoy

Si operas una fintech, un gateway, una IP, o cualquier sistema con endpoints financieros:

  1. Lista todos los endpoints que cambian saldo, status o contador
  2. Para cada uno, valida: ¿existe un lock atómico? ¿Idempotency key?
  3. Prueba manualmente con Turbo Intruder (30 requests paralelos)
  4. Agrega pruebas de race en el CI (las plantillas de arriba)
  5. Si no estás seguro: contrata un pentest enfocado en race conditions

La mayor parte de las fintechs brasileñas que auditamos en 2026 tiene al menos 1 endpoint vulnerable. No porque el equipo sea malo — porque el race condition en HTTP/2 cambió por completo en los últimos 4 años y mucha documentación y muchos blogs antiguos todavía recomiendan patrones insuficientes.

Para conversar sobre un pentest enfocado en Pix y flujos financieros, mira las páginas dedicadas: seguridad Pix, pentest para fintech y pentest para medios de pago. Para entender otras fallas que dominan las APIs en 2026, mira BOLA, BOPLA y BFLA.

Compartir

WhatsAppLinkedInX

Siguiente paso

¿Quieres aplicar esto a tu sistema?

No Vuln hace pentests en profundidad con la misma metodología descrita en este artículo. Solicita una propuesta: NDA bilateral en 24h y alcance definido en una llamada técnica.