Aprende › Vulnerabilidades web

Race conditions web: TOCTOU, limit overrun y Turbo Intruder

· 12 min de lectura

Aprende qué son las race conditions en aplicaciones web, cómo se explotan con técnicas TOCTOU y single-packet attack usando Turbo Intruder, y cómo se corrigen con operaciones atómicas, bloqueos y tokens de idempotencia.

Una aplicación web recibe cientos de peticiones al mismo tiempo. Cuando dos de esas peticiones intentan modificar el mismo dato en el mismo instante, el resultado depende de cuál llega primero y de si el servidor las maneja de forma segura. Si no lo hace, las dos operaciones pueden pasar una validación que solo debería dejar pasar a una, y el atacante termina con el doble de lo que debería tener: dos cupones canjeados, dos transferencias ejecutadas, dos cuentas creadas con el mismo correo.

Eso es una race condition (condición de carrera): un fallo que aparece solo cuando dos o más operaciones concurrentes acceden a un recurso compartido sin la sincronización adecuada. En el mundo web, este recurso suele ser una fila de la base de datos, un archivo temporal o un contador en memoria. La ventana de tiempo entre la comprobación y la acción puede ser de milisegundos, pero con las herramientas correctas basta para explotarla de forma fiable.

Es la undécima lección de la Tanda 9. Verás el patrón TOCTOU, el ataque de limit overrun, cómo usar Turbo Intruder para enviar peticiones en un solo paquete TCP y cómo se corrige cada escenario.

Practica estas técnicas solo en sistemas propios o en entornos autorizados, como los laboratorios de whoami-labs. Explotar race conditions en producción sin permiso puede causar pérdidas económicas reales y es un delito.

Qué es una race condition web#

Una race condition ocurre cuando el resultado de una operación depende del orden en que se ejecutan dos o más hilos, procesos o peticiones concurrentes que acceden al mismo recurso. En una aplicación web, el recurso compartido suele ser una fila de la base de datos: un saldo, un contador de usos de un cupón, un campo que indica si un correo ya está registrado.

Cómo una race condition TOCTOU permite el doble gasto entre la comprobación y la acción
Cómo una race condition TOCTOU permite el doble gasto entre la comprobación y la acción

El problema no está en la lógica del código visto en aislamiento: cada petición, por separado, hace lo correcto. El fallo aparece cuando dos instancias de esa misma lógica se ejecutan a la vez y cada una lee un estado que la otra todavía no ha modificado. La primera comprueba el saldo, la segunda también lo comprueba antes de que la primera lo descuente, y ambas pasan la validación.

Este patrón tiene un nombre clásico en informática: TOCTOU (Time of Check to Time of Use). Entre el momento en que se comprueba una condición (CHECK) y el momento en que se usa (USE), hay una ventana donde otra operación puede cambiar el estado. Si alguien mete una segunda petición en esa ventana, la condición ya no es verdadera cuando se ejecuta la acción, pero el código no lo sabe.

Por qué ocurre: la causa raíz#

Imagina una tienda online que permite canjear un cupón de descuento una sola vez. La lógica en PHP:

// Paso 1: CHECK — ¿ya se usó el cupón?
$usado = $db->query("SELECT usado FROM cupones WHERE codigo = ?", [$codigo]);
if ($usado) {
    return "Cupón ya canjeado";
}

// --- VENTANA DE RACE ---

// Paso 2: USE — marcar como usado y aplicar descuento
$db->query("UPDATE cupones SET usado = 1 WHERE codigo = ?", [$codigo]);
$db->query("UPDATE pedidos SET descuento = 20 WHERE id = ?", [$pedidoId]);

Si dos peticiones llegan al paso 1 antes de que ninguna llegue al paso 2, ambas ven usado = 0 y ambas aplican el descuento. El cupón se usa dos veces.

En Python con Flask y SQLAlchemy el patrón es idéntico:

@app.route("/canjear", methods=["POST"])
def canjear():
    cupon = Cupon.query.filter_by(codigo=codigo).first()
    if cupon.usado:
        return "Ya canjeado", 400

    # --- VENTANA DE RACE ---

    cupon.usado = True
    pedido.descuento = 20
    db.session.commit()
    return "Descuento aplicado"

La causa raíz es siempre la misma: separar la comprobación de la acción sin ningún mecanismo que impida que otra operación se cuele entre ambas.

Tipos de race condition web#

TipoQué ocurreEjemplo
Limit overrunSe supera un límite que debería impedir la repeticiónCanjear un cupón de un solo uso varias veces
TOCTOU en saldoSe comprueba y se descuenta en pasos separadosTransferir más dinero del que hay en la cuenta
Registro duplicadoDos peticiones crean la misma entidad al mismo tiempoDos cuentas con el mismo email
Rate limit bypassLas peticiones llegan antes de que el contador se actualiceSaltarse el límite de intentos de login
File upload raceSe sube un archivo y se valida después de escribirloEjecutar un archivo malicioso antes de que el servidor lo borre

Limit overrun#

Es el más frecuente en bug bounty. Un recurso tiene un límite (un cupón de un solo uso, un bono de bienvenida, votos únicos) y la validación se hace con un SELECT antes del UPDATE. Si envías N peticiones simultáneas, todas leen el estado «no usado» y todas pasan.

TOCTOU en transacciones financieras#

El patrón de doble gasto: comprobar que el saldo alcanza y luego descontar. Dos transferencias de 100 con un saldo de 100 pueden pasar ambas si la comprobación ocurre antes de que cualquiera descuente.

Rate limit bypass#

Muchos sistemas de rate limiting incrementan un contador después de procesar la petición. Si envías 50 peticiones en un solo paquete TCP, todas llegan antes de que el contador suba de 0, y las 50 pasan aunque el límite sea 5.

Dónde aparece#

  • Cupones y códigos de descuento en tiendas online.
  • Transferencias de saldo en aplicaciones fintech y wallets.
  • Sistemas de votación que limitan un voto por usuario.
  • Registro de cuentas con restricción de email o nombre de usuario único.
  • Rate limiting en endpoints de login, OTP y API.
  • Sistemas de reservas donde solo hay un asiento o plaza disponible.
  • Subida de archivos con validación posterior a la escritura en disco.

Cómo se detecta#

Pruebas manuales#

Busca cualquier funcionalidad que haga una comprobación y luego una acción sobre el mismo dato, y sigue estos pasos:

  1. Identifica la petición que realiza la operación limitada (canjear cupón, transferir, votar).
  2. Envíala una vez para confirmar que funciona.
  3. Repite la petición de forma concurrente (20-50 veces simultáneas) con Turbo Intruder o con un script.
  4. Compara los resultados: si más de una petición tuvo éxito cuando solo debería haber pasado una, hay race condition.
PruebaQué buscasQué indica
Canjear cupón 20 veces a la vezMás de un canje exitosoLimit overrun
Transferir saldo completo 2 vecesAmbas transferencias aceptadasTOCTOU en saldo
Login con contraseña incorrecta 50 veces a la vezMás intentos de los que permite el rate limitRate limit bypass
Crear cuenta con el mismo email 10 vecesMás de una cuenta creadaRegistro duplicado

Herramientas#

  • Turbo Intruder (extensión de Burp Suite): envía peticiones en paralelo usando HTTP/2 single-packet attack o HTTP/1.1 last-byte sync. Es la herramienta de referencia para explotar race conditions web.
  • Burp Repeater con la función «Send group in parallel» (disponible desde Burp Suite 2023.9): agrupa varias peticiones y las envía a la vez.
  • Scripts propios en Python con threading o asyncio para enviar peticiones concurrentes.
  • race-the-web: herramienta de línea de comandos diseñada específicamente para probar race conditions.

Explotación paso a paso en un laboratorio#

Vamos a recorrer un escenario típico de limit overrun similar al que encontrarás en los laboratorios SIGNINBLEED y DOORKICK.

Paso 1: identificar la operación limitada#

Navega por la aplicación y encuentra una funcionalidad que debería ejecutarse solo una vez por usuario o que tiene un límite. En este caso, un endpoint para canjear un cupón de descuento:

POST /api/cupon HTTP/1.1
Host: IP_DEL_LAB
Content-Type: application/json
Cookie: session=tu-sesion

{"codigo": "DESCUENTO20"}

La primera petición devuelve 200 OK con {"mensaje": "Cupón aplicado"}. La segunda devuelve 400 con {"error": "Cupón ya usado"}.

Paso 2: preparar Turbo Intruder#

En Burp Suite, envía la petición a Turbo Intruder. Usa el script race-single-packet-attack.py que viene incluido:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           engine=Engine.BURP2)
    # Enviar 20 peticiones en un solo paquete TCP
    for i in range(20):
        engine.queue(target.req, gate='race1')

    # Abrir la puerta: todas salen a la vez
    engine.openGate('race1')

def handleResponse(req, interesting):
    table.add(req)

Paso 3: ejecutar el ataque#

Lanza Turbo Intruder con el script. Las 20 peticiones se envían en un solo paquete TCP (single-packet attack en HTTP/2) o se sincronizan con last-byte sync en HTTP/1.1. Esto minimiza la diferencia de tiempo entre ellas a microsegundos.

Paso 4: analizar los resultados#

Ordena las respuestas por código de estado. Si ves más de una respuesta 200 OK, el cupón se aplicó varias veces. En un laboratorio típico, de 20 peticiones puedes conseguir entre 2 y 5 aplicaciones exitosas, dependiendo del tamaño de la ventana de race.

Petición  1: 200 OK — Cupón aplicado
Petición  2: 200 OK — Cupón aplicado
Petición  3: 200 OK — Cupón aplicado
Petición  4: 400    — Cupón ya usado
Petición  5: 400    — Cupón ya usado
...

Paso 5: confirmar el impacto#

Verifica que el descuento se aplicó varias veces revisando el pedido o el saldo. En un escenario real, esto equivale a obtener múltiples descuentos, bonos de bienvenida o transferencias gratuitas.

Qué puede lograr un atacante#

ImpactoEjemplo
Pérdida económica directaCanjear cupones o bonos múltiples veces
Doble gastoTransferir más saldo del disponible
Bypass de rate limitingSaltarse el límite de intentos de login para hacer fuerza bruta
Duplicación de datosCrear múltiples cuentas con el mismo email
Escalada de privilegiosAplicar un rol o permiso varias veces
Manipulación de votacionesVotar más veces de las permitidas

Cómo se corrige#

Las cuatro capas de defensa contra race conditions: operación atómica, bloqueo, token de idempotencia y serialización
Las cuatro capas de defensa contra race conditions: operación atómica, bloqueo, token de idempotencia y serialización

La clave es eliminar la ventana entre la comprobación y la acción. Hay varias estrategias, y la mejor depende del caso.

Operación atómica con UPDATE condicional#

En vez de hacer un SELECT y luego un UPDATE, haz un solo UPDATE que incluya la condición:

UPDATE cupones SET usado = 1 WHERE codigo = 'DESCUENTO20' AND usado = 0;

Si dos peticiones ejecutan esta sentencia al mismo tiempo, solo una consigue modificar la fila (la otra ve affected_rows = 0 porque la condición usado = 0 ya no se cumple). En PHP:

$stmt = $db->prepare("UPDATE cupones SET usado = 1 WHERE codigo = ? AND usado = 0");
$stmt->execute([$codigo]);
if ($stmt->rowCount() === 0) {
    return "Cupón ya canjeado";
}
// Aplicar descuento solo si el UPDATE modificó una fila

Bloqueo pesimista con SELECT FOR UPDATE#

Dentro de una transacción, bloquea la fila para que nadie más la lea hasta que termines:

BEGIN;
SELECT * FROM cupones WHERE codigo = 'DESCUENTO20' FOR UPDATE;
-- La fila queda bloqueada hasta el COMMIT
UPDATE cupones SET usado = 1 WHERE codigo = 'DESCUENTO20';
COMMIT;

La segunda transacción espera a que la primera haga COMMIT y luego lee el estado ya actualizado.

Token de idempotencia#

Genera un UUID único por operación y almacénalo con una restricción UNIQUE en la base de datos:

@app.route("/canjear", methods=["POST"])
def canjear():
    idempotency_key = request.headers.get("Idempotency-Key")
    try:
        db.execute("INSERT INTO operaciones (key, tipo) VALUES (?, 'cupon')",
                   [idempotency_key])
    except IntegrityError:
        return "Operación ya procesada", 409
    # Proceder con el canje

Restricciones UNIQUE en la base de datos#

Para registros duplicados, una restricción UNIQUE en la columna email garantiza que, aunque dos transacciones intenten insertar al mismo tiempo, solo una lo consiga. La segunda recibirá un error de la base de datos.

Errores comunes al defenderse#

  • Validar solo en el código de aplicación sin usar restricciones de base de datos. Si la base de datos no tiene la restricción, el código siempre tendrá una ventana.
  • Usar un booleano en caché (Redis, memoria) sin operaciones atómicas. Un GET seguido de un SET tiene la misma ventana que un SELECT seguido de un UPDATE. Usa SETNX o INCR de Redis.
  • Pensar que el rate limiting soluciona las race conditions. El rate limiting reduce la velocidad de las peticiones, pero si la ventana existe, reducir la velocidad solo hace el ataque más lento, no imposible.
  • Confiar en que el framework serializa las peticiones. La mayoría de frameworks web procesan peticiones en paralelo con múltiples workers o hilos.
  • Ignorar las race conditions porque la ventana es pequeña. Turbo Intruder puede sincronizar peticiones con precisión de microsegundos. No existe una ventana «demasiado pequeña» para explotar.

Preguntas frecuentes#

¿Las race conditions solo afectan a operaciones financieras?#

No. Cualquier operación que lea un estado, lo valide y luego lo modifique es vulnerable. Registro de cuentas, votaciones, asignación de permisos, consumo de recursos limitados y subida de archivos son ejemplos no financieros.

¿Qué es el single-packet attack?#

Es una técnica descrita por James Kettle (PortSwigger) que aprovecha HTTP/2 para enviar múltiples peticiones completas dentro de un solo paquete TCP. El servidor las recibe todas a la vez, eliminando la variabilidad de red (jitter). En HTTP/1.1, la técnica equivalente es last-byte sync: enviar todas las peticiones menos el último byte y luego enviar todos los últimos bytes de golpe.

¿Un mutex en el código de la aplicación es buena solución?#

Funciona en aplicaciones de un solo proceso, pero en entornos con múltiples workers, contenedores o réplicas, un mutex local no protege contra peticiones que llegan a distintas instancias. En ese caso necesitas un bloqueo distribuido (Redis, base de datos) o una operación atómica.

¿Las race conditions aparecen en el OWASP Top 10?#

No como categoría propia, pero están cubiertas en A04:2021 (Insecure Design) y en la CWE-362 (Concurrent Execution Using Shared Resource with Improper Synchronization). PortSwigger las incluyó como categoría destacada en su Web Security Academy en 2023.

¿Cómo diferencio una race condition de un bug de lógica?#

Si el fallo solo se reproduce con peticiones concurrentes y no con peticiones secuenciales, es una race condition. Si se reproduce con una sola petición, es un bug de lógica de negocio.

Resumen#

  • Una race condition web ocurre cuando dos peticiones concurrentes acceden al mismo dato sin sincronización, y ambas pasan una validación que solo debería dejar pasar a una.
  • El patrón TOCTOU (Time of Check to Time of Use) es la causa raíz: hay una ventana entre comprobar y actuar donde otra petición puede colarse.
  • Turbo Intruder y la técnica de single-packet attack permiten sincronizar peticiones con precisión de microsegundos para explotar la ventana.
  • Se corrige con operaciones atómicas (un solo UPDATE con condición WHERE), bloqueos pesimistas (SELECT FOR UPDATE), tokens de idempotencia o restricciones UNIQUE en la base de datos.
  • El rate limiting no soluciona race conditions: solo ralentiza el ataque.
  • Las race conditions afectan a cualquier operación limitada: cupones, saldos, votos, registros, permisos y rate limits.

Fuentes y lecturas recomendadas#

Practícalo en estos laboratorios

Ver todos los laboratorios de esta categoría