Condições de corrida e TOCTOU (CWE-362, CWE-367): como requisições paralelas furam verificações e como corrigir
Um código que lê um valor, verifica e depois grava deixa duas requisições que chegam no mesmo instante passarem pela verificação, e assim um vale-presente é resgatado duas vezes ou um saldo fica negativo. Como funciona a janela entre verificar e agir, por que verificações na aplicação não conseguem fechá-la e como um único UPDATE ... RETURNING condicional, bloqueios de linha e restrições do banco tornam a operação atômica, com um exemplo em Python e PostgreSQL.
Explicação em Linguagem Simples (ELI5)
Um cinema tem dois caixas e só um lugar livre na fileira F. Os dois olham o mapa de assentos no mesmo segundo, ambos veem a poltrona F7 livre e ambos a vendem. Cada caixa conferiu antes de vender; o problema é que a conferência e a venda foram dois momentos separados, e o outro caixa agiu no meio. A correção é um único bloco de ingressos do qual os dois precisam destacar: quem destacar o último F7 fica com ele, e o outro encontra o bloco vazio. Conferir e pegar viram uma só ação.
Conceitos Centrais e Termos
- TOCTOU (do momento da verificação ao momento do uso)
- O estado verificado pode mudar antes de ser usado. Em código web, a verificação costuma ser um
SELECTe o uso é umUPDATEposterior, com outras requisições rodando no meio. - Janela de corrida e requisições paralelas
- A janela muitas vezes é de poucos milissegundos, mas atacantes conseguem alinhar dezenas de requisições para chegarem juntas. A pesquisa da PortSwigger de 2023 mostrou que enviar os últimos bytes de muitas requisições HTTP/2 num único pacote faz com que cheguem com cerca de um milissegundo de diferença.
- Atualização condicional atômica
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...verifica e altera a linha numa única instrução. Só um chamador recebe uma linha de volta; os outros não recebem nenhuma.- Bloqueios de linha em Read Committed
- No PostgreSQL, um segundo
UPDATEna mesma linha espera a primeira transação e depois reavalia a cláusulaWHEREcontra a linha já confirmada. Essa reverificação é o que torna a atualização condicional segura sem elevar o nível de isolamento. - Restrições e chaves de idempotência
- Índices
UNIQUEeCHECK (balance_cents >= 0)fazem uma corrida perdida falhar de forma visível em vez de gastar em dobro. UmaIdempotency-Keyguardada sob um índice único permite que clientes repitam pagamentos sem duplicá-los.
Fluxo de Ataque Passo a Paso
O atacante tem um vale-presente válido
Ele tem um único vale de valor fixo e uma conta para receber o crédito.
Ele envia muitos resgates ao mesmo tempo
Vinte requisições com o mesmo código chegam dentro de um milissegundo. Duas requisições paralelas usando o código de teste TEST-0001 ilustram o caso.
Todas as requisições passam pela verificação
Cada SELECT roda antes que qualquer requisição tenha marcado o vale como resgatado, então todas veem redeemed = false.
O saldo é creditado várias vezes
Cada requisição soma o valor do vale antes de a marca ser gravada. Em 2015 Egor Homakov mostrou que transferências paralelas entre cartões-presente da Starbucks podiam criar saldo do nada.
Código-Fonte: Vulnerável vs. Seguro
# gift_cards.py: verificação e uso são instruções separadas
from .db import pool
# toda requisição paralela pode ver redeemed = false antes que qualquer uma grave
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn:
card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
if card is None or card[2]:
raise ValueError("invalid or used card")
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
return card[1]
# gift_cards.py: verificação e uso acontecem numa só instrução
from .db import pool
# só um chamador recebe uma linha; os demais esperam o lock e veem NOT redeemed falhar
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn, conn.transaction():
card = conn.execute(
"UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
"WHERE code = %s AND NOT redeemed RETURNING balance_cents",
(user_id, code),
).fetchone()
if card is None:
raise ValueError("invalid or used card")
# crédito na mesma transação, então as duas mudanças são confirmadas juntas ou nenhuma
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
Lista de Verificação de Segurança para Engenharia
- Substitua sequências de ler, verificar e gravar sobre dinheiro, estoque, cupons e cotas por um único
UPDATE ... WHERE ... RETURNINGcondicional, ou porSELECT ... FOR UPDATEdentro de uma transação. - Proteja ações de uso único com restrições do banco (
UNIQUE,CHECK (balance_cents >= 0)) para que uma corrida perdida falhe em vez de dar certo duas vezes. - Aceite uma
Idempotency-Keynos endpoints de pagamento e resgate e guarde-a sob um índice único. - Não dependa de locks ou flags dentro do processo quando mais de um worker ou servidor atende as requisições.
- Adicione um teste de concorrência que dispare 20 resgates paralelos de um mesmo vale de teste e verifique que exatamente um funciona.