● CWE-362 · CWE-367

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 SELECT y el uso un UPDATE posterior, 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 UPDATE sobre la misma fila espera a la primera transacción y luego vuelve a evaluar su cláusula WHERE sobre 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 UNIQUE y CHECK (balance_cents >= 0) hacen que una carrera perdida falle de forma visible en lugar de gastar dos veces. Una Idempotency-Key guardada con un índice único permite a los clientes reintentar pagos sin repetirlos.

Flujo de Ataque Paso a Paso

Paso 1

El atacante tiene una tarjeta regalo válida

Tiene una sola tarjeta de importe fijo y una cuenta en la que abonarla.

Paso 2

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.

Paso 3

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.

Paso 4

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

IMPLEMENTACIÓN VULNERABLE
# 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]
PARCHE SEGURO Y ROBUSTO
# 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

Fuentes

← Ver el directorio completo de seguridad Todas las guías de vulnerabilidades →