Uma race condition (condição de corrida) acontece quando o resultado de uma operação depende da ordem ou do tempo em que requisições concorrentes são processadas. Em aplicações web, a aparência de execução sequencial é uma ilusão: o servidor atende dezenas de requisições em paralelo, e quando duas delas tocam o mesmo estado — um saldo, um contador de uso de cupom, um número de tentativas de senha — sem coordenação adequada, a aplicação pode aplicar uma regra de negócio menos vezes do que deveria. O atacante não quebra a criptografia nem injeta payload nenhum; ele apenas chega na hora certa, várias vezes ao mesmo tempo.
O caso clássico é o TOCTOU (time-of-check to time-of-use): a aplicação verifica uma condição (“o cupom ainda não foi usado”) e só depois age sobre ela (“marcar como usado”), e entre esses dois passos existe uma janela em que outra requisição executa a mesma checagem com o mesmo resultado. Resgatar o mesmo gift-card cinco vezes, sacar o saldo duas vezes, burlar o limite de tentativas de MFA — tudo isso são instâncias do mesmo defeito estrutural. E desde a pesquisa de James Kettle sobre o single-packet attack (2023), explorar essas janelas ficou muito mais confiável, mesmo em janelas de microssegundos.
Como funciona
O padrão vulnerável quase sempre tem o formato check-then-act (verifica-então-age) executado de forma não atômica. Considere o resgate de um cupom de desconto:
# VULNERÁVEL: check-then-act sem atomicidade
@app.post("/cupom/resgatar")
def resgatar():
codigo = request.json["codigo"]
cupom = db.query("SELECT * FROM cupons WHERE codigo = %s", codigo)
# 1) CHECK: o cupom ainda está disponível?
if cupom.usado:
return {"erro": "cupom já utilizado"}, 400
# ... lógica de negócio, crédito na carteira ...
creditar_saldo(current_user, cupom.valor)
# 2) ACT: marca como usado (TARDE DEMAIS)
db.execute("UPDATE cupons SET usado = TRUE WHERE codigo = %s", codigo)
return {"ok": True}
Com uma requisição por vez, isso funciona. O problema aparece quando duas requisições chegam praticamente juntas e ambas executam o SELECT antes de qualquer uma chegar no UPDATE. Visualmente, a linha do tempo concorrente fica assim:
Requisição A: SELECT (usado=FALSE) ──┐
Requisição B: SELECT (usado=FALSE) ──┤ <- ambas leem o estado antigo
Requisição A: creditar_saldo() │
Requisição B: creditar_saldo() │ <- crédito DUPLICADO
Requisição A: UPDATE usado=TRUE ───┤
Requisição B: UPDATE usado=TRUE ───┘ <- inofensivo, já era TRUE
As duas leram usado = FALSE, as duas passaram pela checagem, as duas creditaram o saldo. A janela entre o SELECT e o UPDATE — muitas vezes apenas alguns milissegundos de lógica de aplicação, espera de I/O ou round-trip ao banco — é exatamente onde o ataque mora. Esse intervalo é chamado de race window (janela de corrida), e o objetivo do atacante é encaixar o máximo de requisições dentro dele.
A versão crua, em HTTP, do que se quer disparar é trivial — a “graça” está em mandar muitas simultaneamente:
POST /cupom/resgatar HTTP/2
Host: app.exemplo.com
Cookie: session=eyJ...
Content-Type: application/json
Content-Length: 22
{"codigo":"WELCOME50"}
Variações e bypasses
O mesmo defeito aparece em várias formas. Vale conhecer os arquétipos porque o ponto de injeção e a evidência mudam.
Limit-overrun (ultrapassar um limite). É a categoria mais lucrativa: qualquer ação com um limite numérico que é verificado antes de ser aplicado. Saque/transferência além do saldo (double-spend), resgatar o mesmo crédito N vezes, aplicar o mesmo voucher repetidamente no carrinho, exceder cota de convites, ou ultrapassar o limite de itens em uma promoção “1 por cliente”.
Bypass de limite de tentativas (rate-limit / anti-bruteforce). Se o contador de tentativas de senha ou de código MFA é do tipo ler-incrementar-gravar, disparar 50 palpites de OTP simultaneamente pode fazer com que todos sejam avaliados antes de o contador chegar ao limite. O efeito é um bruteforce de um código de 6 dígitos que deveria ser bloqueado após 3 tentativas.
Single-endpoint vs. multi-endpoint races. O caso mais comum colide a mesma rota consigo mesma (vários resgates do mesmo cupom). Mas há a variante multi-endpoint: duas rotas diferentes que tocam o mesmo estado e cuja ordem de execução não é garantida — por exemplo, aplicar um cupom enquanto, em paralelo, se confirma o pedido, fazendo o desconto “vazar” para um estado já fechado. Esses exigem sincronizar requisições de endpoints distintos no mesmo instante.
TOCTOU em arquivos e estado fora do banco. Nem toda corrida é no banco relacional. Upload que valida o tipo do arquivo e depois o move; verificação de permissão em cache que expira entre o check e o use; flags em Redis manipuladas com GET/SET separados em vez de operações atômicas. O princípio é idêntico: a checagem e o uso não compartilham o mesmo “cadeado”.
O bypass que viabiliza tudo: single-packet attack. O obstáculo histórico para explorar essas janelas era o jitter de rede: mandar 20 requisições “ao mesmo tempo” pela internet significava que elas chegavam espalhadas por dezenas de milissegundos, frequentemente largas demais para a janela de corrida. A técnica do single-packet attack (Kettle, 2023) resolve isso usando HTTP/2: o cliente envia quase todas as requisições, segurando um pequeno fragmento final de cada uma, e então libera todos os fragmentos finais em um único pacote TCP. O servidor recebe o fechamento de todas as requisições praticamente ao mesmo tempo e as processa em paralelo, eliminando o jitter de rede da equação. Na prática, a técnica acomoda de forma confiável 20–30 requisições por pacote (limitada pelo MTU de ~1500 bytes), o que costuma bastar para a maioria das corridas. Vale a ressalva: o single-packet ataca o jitter de rede; o jitter do lado do servidor (variação no tempo de processamento por contenção de CPU, etc.) não é eliminado — é justamente por isso que se dispara dezenas de requisições, e não apenas duas. Quando o alvo só fala HTTP/1.1, o fallback é a clássica sincronização do último byte (last-byte sync), que não é tão precisa, mas ainda estreita bastante a dispersão de chegada.
Como exploramos no pentest
A exploração é metódica e a ferramenta de referência é o Turbo Intruder (extensão do Burp Suite), que implementa o single-packet attack. O Burp Repeater também suporta a técnica via grupos de abas (“Send group in parallel”), útil para um disparo rápido antes de scriptar.
1. Mapear ações limitadas que mudam estado. Procuramos qualquer operação com uma regra de “só pode acontecer uma vez” ou “no máximo X”: resgate de cupom/gift-card, saque, transferência, aplicação de desconto, voto, validação de OTP, “1 por cliente”, criação de recurso com limite de plano. Toda checagem de limite seguida de mutação é suspeita.
2. Estabelecer o baseline. Executamos a ação uma vez de forma legítima e registramos o resultado esperado (saldo final, “cupom usado”, contador). É contra esse baseline que o overrun vai aparecer.
3. Disparar em paralelo com single-packet. Enviamos a mesma requisição (ou o conjunto multi-endpoint) muitas vezes em simultâneo. No Turbo Intruder, usa-se o engine Engine.BURP2 com concurrentConnections=1, enfileirando as cópias com engine.queue(..., gate=...) e disparando todas com engine.openGate():
# Turbo Intruder — gatilho single-packet (HTTP/2)
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2, # stack HTTP/2 do Burp; requer alvo com HTTP/2
)
# Enfileira 30 cópias da MESMA requisição, presas no "portão"
for i in range(30):
engine.queue(target.req, gate='race1')
# Libera todos os fragmentos finais no mesmo pacote
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
Para corridas multi-endpoint, enfileiram-se requisições diferentes no mesmo
gate(substituindotarget.reqpelo texto bruto de cada requisição) — por exemplo,POST /cupom/aplicarePOST /pedido/confirmar— para que ambas atravessem a janela juntas. Ao depurar a sincronização, vale “aquecer” a conexão com uma requisição inócua antes do grupo, para que a latência de abertura de conexão não distorça a medição.
4. Observar o overrun. A confirmação visual é direta: o cupom marcado como usado 5 vezes, saldo que vai a negativo, várias respostas 200 OK para uma ação que deveria retornar 400 após a primeira, ou o OTP aceito após dezenas de tentativas que o rate-limit deveria ter cortado. Anexamos a tabela de respostas do Turbo Intruder mostrando múltiplos sucessos para a mesma operação.
5. Quantificar o impacto. Diferenciamos “5 créditos duplicados” de “saldo arbitrário negativo”: o segundo costuma ser Crítico. Sempre validamos que o resultado é o overrun real (estado inconsistente persistido no banco/painel), não apenas respostas concorrentes inofensivas — duas respostas 200 que convergem para o mesmo estado final não constituem a vulnerabilidade.
Resumo para o relatório
- Impacto: ação com limite de negócio aplicada mais vezes do que o permitido — ex.: mesmo cupom/gift-card resgatado N vezes, saldo levado a negativo (double-spend), bypass de limite de tentativas de OTP/senha. Resulta em perda financeira direta, fraude ou comprometimento de conta.
- Severidade: Alta a Crítica (CVSS na faixa ~8.1–9.1 quando há perda financeira ou bypass de autenticação; tipicamente vetor de rede, baixa complexidade após identificada a janela). A pontuação exata depende do contexto — calcule pelo impacto real demonstrado.
- Pré-condições: uma ação que muda estado com padrão check-then-act não atômico; capacidade de enviar requisições concorrentes (HTTP/2 facilita via single-packet, mas não é estritamente obrigatório). Muitos alvos exigem autenticação, mas não todos.
- Evidência sugerida: requisição original; saída do Turbo Intruder com múltiplas respostas
200/sucesso para a operação que deveria ser única; estado resultante (saldo negativo, contadorusos = 5, cupom usado várias vezes) com prints do banco/painel antes e depois; e o resultado do baseline (1 requisição) para contraste.
Como mitigar
A regra única é: torne a operação atômica e imponha o limite na camada do banco de dados, nunca apenas em memória ou na aplicação. A aplicação enxerga estado “congelado” no momento do SELECT; o banco é o único árbitro que vê todas as transações concorrentes.
1. Lock pessimista de linha com SELECT ... FOR UPDATE
Abra uma transação, bloqueie a linha do recurso e só então cheque e atualize. A segunda transação concorrente fica em espera até a primeira terminar — e aí já lê o estado novo:
# SEGURO: a checagem e a ação acontecem dentro da MESMA transação,
# com a linha travada. A 2ª requisição espera e vê usado=TRUE.
@app.post("/cupom/resgatar")
def resgatar():
codigo = request.json["codigo"]
with db.transaction():
cupom = db.query(
"SELECT * FROM cupons WHERE codigo = %s FOR UPDATE", # <- lock de linha
codigo,
)
if cupom.usado:
return {"erro": "cupom já utilizado"}, 400
db.execute("UPDATE cupons SET usado = TRUE WHERE codigo = %s", codigo)
creditar_saldo(current_user, cupom.valor)
return {"ok": True}
Observação:
FOR UPDATEsó serializa de fato sob um nível de isolamento adequado e quando todas as transações concorrentes passam pelo mesmo lock de linha. EmREPEATABLE READ/SERIALIZABLE(padrão no PostgreSQL para snapshots), esteja preparado para tratar erros de serialização e fazer retry.
2. Faça o banco impor o limite atomicamente
Melhor ainda: elimine a janela escrevendo a condição dentro do próprio UPDATE, de modo que o banco decida quem vence. Para saldo, isso evita o negativo de forma definitiva:
-- O UPDATE só afeta linhas que ainda satisfazem a condição.
-- Em concorrência, apenas UMA transação consegue rowcount = 1.
UPDATE contas
SET saldo = saldo - 100
WHERE id = 42
AND saldo >= 100; -- a checagem e a ação são a MESMA operação atômica
Se rowcount = 0, não havia saldo — rejeite. Para cupons de uso único, deixe a constraint de unicidade fazer o trabalho: uma tabela resgates(cupom_id ... UNIQUE) faz o segundo INSERT falhar por violação de chave, e o banco nunca permite o duplo resgate, independentemente do timing.
3. Locking otimista (versionamento)
Quando travar linhas é caro, use uma coluna version. A escrita só vale se a versão não mudou; se mudou, alguém chegou antes e você repete a leitura:
UPDATE pedidos
SET status = 'pago', version = version + 1
WHERE id = 7 AND version = 3; -- falha (rowcount=0) se a versão já avançou
4. Idempotency keys para duplicação acidental
Para evitar que um clique duplo ou retry de rede execute a ação duas vezes, exija uma chave de idempotência única por operação. A primeira requisição grava a chave (com constraint UNIQUE); a segunda colide e recebe o mesmo resultado, sem reexecutar o efeito:
POST /pagamentos HTTP/2
Idempotency-Key: 4e1c2a9f-7b3d-4f8a-9c10-2de5b1a07f33
Content-Type: application/json
{"valor": 100, "destino": "conta-99"}
Note que idempotency keys neutralizam duplicação acidental (retry/duplo clique), mas não substituem o lock: um atacante simplesmente envia chaves diferentes a cada requisição. Para limit-overrun, a atomicidade no banco (itens 1–2) é o controle primário.
5. Defesa em profundidade
Locks distribuídos (ex.: Redlock no Redis) e contadores atômicos (INCR) protegem estado fora do banco relacional. Trate locks distribuídos como conveniência de coordenação, não como garantia de correção sob falha — a segurança de algoritmos como o Redlock é debatida e depende de premissas de timing; para garantir unicidade, prefira sempre o árbitro transacional (constraint/UPDATE atômico) na fonte da verdade. Mantenha rate-limiting robusto contra tentativas de OTP/senha, com incremento atômico no store (e não read-modify-write).
Nunca confie em
if (recurso.disponivel) { usar(recurso); }em memória. Entre oife ousar, outra requisição já pode ter “usado” o mesmo recurso. O cadeado precisa envolver os dois passos.
Checklist de mitigação
- Eliminar o padrão check-then-act em memória; checar e agir dentro da mesma transação.
- Usar
SELECT ... FOR UPDATE(lock pessimista) nas linhas de recursos limitados, com tratamento de retry em erros de serialização. - Impor o limite no próprio
UPDATE ... WHERE saldo >= Xe validar o rowcount. - Adicionar constraints de UNICIDADE no banco para ações de uso único (resgates, votos).
- Locking otimista (coluna
version) onde travar linhas é custoso. - Idempotency keys com
UNIQUEpara neutralizar duplicação por retry/duplo clique (complementa, não substitui, o lock). - Contadores atômicos (
INCR) e locks distribuídos para estado em cache/Redis — como camada, não como única garantia. - Rate-limiting de OTP/senha resistente a corrida (incremento atômico, não read-modify-write).
- Testar concorrência no CI: disparar a ação em paralelo e afirmar que o limite se mantém.
Condições de corrida são o lembrete de que “o código está logicamente correto” e “o código está correto sob concorrência” são afirmações diferentes. A janela entre verificar e usar é invisível em qualquer leitura sequencial do código — ela só existe no tempo. Quem defende precisa fechá-la na única camada que enxerga todas as requisições ao mesmo tempo: o banco de dados. Quem ataca só precisa de um pacote bem cronometrado.