Condiciones de carrera y TOCTOU (CWE-362, CWE-367): cómo las peticiones paralelas rompen las comprobaciones y cómo corregirlo
Un código que lee un valor, lo comprueba y luego escribe deja que dos peticiones que llegan en el mismo instante superen ambas la comprobación, así que una tarjeta regalo se canjea dos veces o un saldo queda en negativo. Cómo funciona la ventana entre comprobar y actuar, por qué las comprobaciones en la aplicación no pueden cerrarla y cómo un único UPDATE ... RETURNING condicional, los bloqueos de fila y las restricciones de la base de datos hacen atómica la operación, con un ejemplo en Python y PostgreSQL.
Explicación en Lenguaje Sencillo (ELI5)
Un cine tiene dos taquilleros y un solo asiento libre en la fila F. Los dos miran el plano de butacas en el mismo segundo, ambos ven libre la F7 y ambos la venden. Cada uno comprobó antes de vender; el problema es que comprobar y vender fueron dos momentos distintos, y el otro taquillero actuó entre medias. La solución es un único taco de entradas del que ambos tienen que arrancar: quien arranque la última F7 se la queda, y el otro encuentra el taco vacío. Comprobar y tomar pasan a ser una sola acción.
Conceptos Clave y Términos
- TOCTOU (del momento de comprobación al momento de uso)
- El estado comprobado puede cambiar antes de usarse. En código web, la comprobación suele ser un
SELECTy el uso unUPDATEposterior, con otras peticiones ejecutándose entre medias. - Ventana de carrera y peticiones paralelas
- La ventana suele durar unos pocos milisegundos, pero los atacantes pueden alinear decenas de peticiones para que lleguen juntas. La investigación de PortSwigger de 2023 mostró que enviar los últimos bytes de muchas peticiones HTTP/2 en un solo paquete las hace llegar con alrededor de un milisegundo de diferencia.
- Actualización condicional atómica
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...comprueba y cambia la fila en una sola sentencia. Solo un llamador recibe una fila; los demás no reciben ninguna.- Bloqueos de fila en Read Committed
- En PostgreSQL, un segundo
UPDATEsobre la misma fila espera a la primera transacción y luego vuelve a evaluar su cláusulaWHEREsobre la fila confirmada. Esa segunda comprobación es lo que hace segura la actualización condicional sin subir el nivel de aislamiento. - Restricciones y claves de idempotencia
- Los índices
UNIQUEyCHECK (balance_cents >= 0)hacen que una carrera perdida falle de forma visible en lugar de gastar dos veces. UnaIdempotency-Keyguardada con un índice único permite a los clientes reintentar pagos sin repetirlos.
Flujo de Ataque Paso a Paso
El atacante tiene una tarjeta regalo válida
Tiene una sola tarjeta de importe fijo y una cuenta en la que abonarla.
Envía muchos canjes a la vez
Veinte peticiones con el mismo código llegan en el mismo milisegundo. Dos peticiones paralelas con el código de prueba TEST-0001 ilustran el caso.
Todas las peticiones superan la comprobación
Cada SELECT se ejecuta antes de que ninguna petición haya marcado la tarjeta como canjeada, así que todas ven redeemed = false.
El saldo se abona muchas veces
Cada petición suma el importe de la tarjeta antes de que se guarde la marca. En 2015 Egor Homakov mostró que las transferencias paralelas entre tarjetas regalo de Starbucks podían crear saldo de la nada.
Código Fuente: Vulnerable vs. Seguro
# gift_cards.py: comprobación y uso son sentencias separadas
from .db import pool
# cada petición paralela puede ver redeemed = false antes de que ninguna escriba
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: comprobación y uso ocurren en una sola sentencia
from .db import pool
# solo un llamador recibe una fila; el resto espera el bloqueo y ve fallar NOT redeemed
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")
# abono en la misma transacción, así que ambos cambios se confirman juntos o ninguno
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
Lista de Verificación de Seguridad para Ingeniería
- Sustituye las secuencias de leer, comprobar y escribir sobre dinero, stock, cupones y cuotas por un único
UPDATE ... WHERE ... RETURNINGcondicional, o porSELECT ... FOR UPDATEdentro de una transacción. - Respalda las acciones de un solo uso con restricciones de la base de datos (
UNIQUE,CHECK (balance_cents >= 0)) para que una carrera perdida falle en vez de salir bien dos veces. - Acepta una
Idempotency-Keyen los endpoints de pago y canje y guárdala con un índice único. - No confíes en bloqueos o banderas dentro del proceso cuando más de un worker o servidor atienden las peticiones.
- Añade una prueba de concurrencia que lance 20 canjes paralelos de una misma tarjeta de prueba y compruebe que exactamente uno tiene éxito.