Race condition em PIX: o bug que paga 2x devolução
Race condition em devolução PIX é o bug mais subestimado de fintech brasileira em 2026. Análise técnica do HTTP/2 single-packet attack em endpoints financeiros.
Pesquisador de segurança e fundador da No Vuln

Race condition em endpoints de PIX é o bug mais subestimado de fintech brasileira em 2026. Não é teórico. Está em produção em quase toda fintech que não passou por pentest focado em race conditions HTTP/2. Quando explorado, transfere dinheiro real do banco para a conta atacante.
Esse post é análise técnica do padrão, com fluxo de exploração anonimizado e checklist de correção. Se você opera fintech, IP autorizada, gateway de pagamento ou qualquer sistema que processa fluxo financeiro com endpoint de devolução, refund, saque ou estorno — é leitura obrigatória.
O bug em 1 frase
Endpoint que processa devolução (ou saque, ou estorno) sem lock atômico aceita múltiplas execuções paralelas do mesmo pedido, transferindo dinheiro mais de uma vez antes de marcar o pedido como “já processado”.
Por que HTTP/2 single-packet attack mudou o jogo
Race conditions sempre existiram. O que mudou em 2020+ foi a capacidade técnica de disparar dezenas de requisições no mesmo pacote TCP via HTTP/2.
Pré-HTTP/2: cliente envia request 1, aguarda ACK, envia request 2. A latência de rede entre requests era de ~50-200ms. Race condition exige janela de microsegundos pra explorar de verdade. Em prática, raras.
Pós-HTTP/2 + multiplexing + single-packet attack: cliente envia 30 requests no MESMO pacote TCP. Servidor processa quase simultaneamente. Janela entre requests: microsegundos. Race condition que era teórica vira explorável trivialmente.
Ferramentas como Turbo Intruder (extensão do Burp Pro) implementam isso de forma nativa. Não é técnica obscura — é prática padrão em pentest moderno.
O endpoint vulnerável típico
Endpoint clássico de devolução PIX:
POST /api/v1/pix/transactions/{tx_id}/refundAuthorization: Bearer <token>
Implementação ingênua (pseudo-código):
- Carrega a transação:
transaction = Transaction.get(tx_id) - Checa:
if transaction.status == 'refunded': raise AlreadyRefundedError() - ← janela de race aqui ←
- Transfere:
bank_api.transfer_back(payer, amount) - Atualiza status:
transaction.status = 'refunded'; transaction.save()
Entre o check if status == 'refunded' e o transaction.save() existe janela onde outra request paralela passa pelo mesmo check.
Resultado: 30 requests simultâneos → 30 transferências bancárias enquanto pedido marcado como “não devolvido ainda”.
A exploração na prática
Usando Turbo Intruder (script Python em Burp Pro):
- Configurar engine com
requestsPerConnection=30eengine=Engine.BURP2 - Enfileirar 30 requests POST idênticos com
gate='race1' - Abrir o gate:
engine.openGate('race1')— libera todos simultaneamente - Observar responses
Resultado em fintech vulnerável:
- 1 request: 200 OK (devolução processada)
- 29 requests: 200 OK também (todos processaram)
- Conta bancária do “cliente” recebeu 30x R$ 1.000 = R$ 30.000
- Status da transação:
refunded(1 vez no DB)
Atacante gasta R$ 1.000 em uma compra, devolve, recebe R$ 30.000 de volta. Não é teórico — é o padrão exato que vemos repetir.
Onde isso aparece em fintech brasileira
Não é só PIX. Em pentest, encontramos race condition em:
Endpoints financeiros
- Devolução PIX (cenário acima)
- Estorno de cartão
- Saque de saldo
- Antecipação de recebível
- Resgate de investimento
Endpoints de “limite”
- Cupom single-use (cupom de R$ 100 usado 30x = R$ 3.000 desconto)
- Cashback (race em cancelamento = cashback dobrado)
- Voucher de promoção(limite “100 vouchers” virou 130)
Endpoints de autenticação
- MFA bypass (race em validação de código OTP)
- Bypass de rate limit (30 requests antes do counter incrementar)
- Account recovery race
Endpoints de estado
- Aprovação de transferência(aprovar 2x antes de virar “aprovada”)
- Confirmação de pedido (estoque negativo)
- Mudança de plano em SaaS (downgrade após cobrança, double upgrade)
Como corrigir — soluções erradas vs corretas
❌ Solução errada 1: time.sleep()
Adicionar time.sleep(0.1) entre o check e o write não resolve. Só reduz a janela. Trivialmente contornado com mais requests ou ferramenta mais agressiva.
❌ Solução errada 2: Lock em memória do Python
Usar threading.Lock() ou similar não resolve em produção. Lock só funciona dentro do mesmo processo. Em produção com múltiplos workers/instâncias (gunicorn, uwsgi, kubernetes), cada processo tem seu próprio lock. Race entre processos continua.
✅ Solução correta 1: Lock atômico no banco (PostgreSQL SELECT FOR UPDATE)
Dentro de uma transação do banco, usar SELECT FOR UPDATE bloqueia a linha no PostgreSQL. Outras requests aguardam até a primeira commit ou rollback. Pseudo-fluxo:
- Abrir transação DB
transaction = Transaction.select_for_update(tx_id)— adquire lock- Checar status
- Transferir dinheiro
- Atualizar status e salvar
- Commit (lock liberado automaticamente)
Funciona em ambiente distribuído (todos os workers compartilham o mesmo DB). É o baseline mínimo para qualquer endpoint financeiro.
✅ Solução correta 2: Idempotency Key (padrão Stripe / Adyen / Mercado Pago)
Cliente envia header Idempotency-Key: <uuid-único-por-operação>. Servidor:
- Verifica se a chave já foi processada (consulta em tabela ou cache)
- Se já foi: devolve a resposta cacheada (não reprocessa)
- Se não foi: cria registro com lock, processa, salva resposta, marca como completa
- Em retries ou races: chave existe → resposta cacheada devolvida
Esse é o padrão usado por Stripe, Adyen, Mercado Pago e qualquer adquirente sério. Não é opcional em fintech séria — é mandatório.
✅ Solução correta 3: Lock distribuído (Redis)
Quando a lógica atravessa múltiplos serviços (microservices) e DB lock não cobre, use Redis lock distribuído (Redlock pattern ou similar). Bloqueia em recurso lógico, não no DB.
Os 3 testes que toda fintech deveria ter no CI
Teste 1: Refund não duplicado sob race
Cria 1 transação, dispara 30 requests paralelas via threading, espera assert success_count == 1. Se mais de 1 passar, race aberto.
Teste 2: Idempotency key enforced
Faz request sem header Idempotency-Key, espera assert response.status == 400. Garante que endpoint financeiro rejeita requests sem idempotency.
Teste 3: Mesma idempotency key = mesma resposta
Faz 2 requests com mesma key, compara responses, espera assert r1.json() == r2.json() e assert get_processed_count() == 1.
CI que falha se race for reintroduzido. Esse é o controle preventivo definitivo.
O custo de não corrigir
Casos reais que vimos (anonimizados):
| Endpoint vulnerável | Impacto máximo se explorado |
|---|---|
| Devolução PIX | Transferência bancária 30x do valor original |
| Cupom single-use 50% off | Cupom usado N vezes em pedidos paralelos |
| Saque de saldo | Saque maior que saldo disponível (saldo negativo) |
| MFA OTP | Bypass de 2FA (race entre check e expiração) |
| Estorno cartão | Crédito de N estornos via 1 transação |
Em fintech regulada pelo Bacen, esse bug:
- Viola Manual de Segurança PIX (controles operacionais)
- Cria incidente reportável (BCB Resolução 1/2020)
- Tem impacto financeiro direto e imediato (não potencial)
- Pode disparar auditoria não programada se reincidente
O que fazer hoje
Se você opera fintech, gateway, IP, ou qualquer sistema com endpoints financeiros:
- Listar todos endpoints que mudam saldo, status ou contador
- Para cada um, validar: existe lock atômico? Idempotency key?
- Testar manualmente com Turbo Intruder (30 requests paralelas)
- Adicionar testes de race no CI (templates acima)
- Se não tem certeza: contratar pentest focado em race conditions
A maior parte das fintechs brasileiras que auditamos em 2026 tem pelo menos 1 endpoint vulnerável. Não porque o time é ruim — porque race condition em HTTP/2 mudou completamente nos últimos 4 anos e muita doc/blog antigos ainda recomendam padrões insuficientes.
Para conversa sobre pentest focado em PIX e fluxos financeiros, veja as páginas dedicadas: /seguranca-pix, /pentest-fintech e /pentest-meios-pagamento. Para entender outras falhas que dominam APIs em 2026, veja BOLA, BOPLA e BFLA.
Próximo passo
Quer aplicar isso ao seu sistema?
A No Vuln faz pentest profundo com a mesma metodologia descrita neste artigo. Solicite uma proposta — NDA bilateral em 24h, escopo definido em call técnica.